Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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),)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -190,6 +190,17 @@ sudo ./install.sh --version=v1.0.0-beta.2

Release artifacts per tag: seven binaries (`edgelet-linux-<arch>`, `edgelet-darwin-<arch>`, `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
Expand Down Expand Up @@ -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 |

Expand Down
38 changes: 3 additions & 35 deletions cmd/edgelet-server/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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")
}
35 changes: 3 additions & 32 deletions cmd/edgelet/daemon_desktop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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")
}
5 changes: 4 additions & 1 deletion cmd/edgelet/daemon_signal_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ import (
"syscall"

"github.com/eclipse-iofog/edgelet/internal/supervisor"
"github.com/eclipse-iofog/edgelet/internal/utils/logging"
)

func registerDaemonSignals(sigChan chan os.Signal) {
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
}

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 {
Expand Down
14 changes: 12 additions & 2 deletions docs/edgelet/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions docs/edgelet/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>` (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:
Expand Down
67 changes: 46 additions & 21 deletions docs/edgelet/examples/controlplane.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: "<replace-with-realm-public-key-pem-or-base64>"
ssl: external # ssl mode (e.g. external)
controllerClient: pot-controller
controllerSecret: "<replace-me>"
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: "<replace-me>" # 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: "<replace-me>"
# 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
Expand All @@ -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
Expand All @@ -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: "<base64>" # optional
# ca: "<base64>" # optional — TLS_BASE64_INTERMEDIATE_CERT
# cert: "<base64>" # required when base64 block is used
# key: "<base64>" # required when base64 block is used

Expand All @@ -100,4 +126,3 @@ spec:
# google:
# projectId: ""
# credentials: "" # service account JSON or path

Loading
Loading