From 67c46fd3beb5fa64d44854295acd62ba06523979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 02:24:43 +0300 Subject: [PATCH 01/10] Adopt ControlPlane manifest v3.8 schema and canonical env projection. Reject legacy YAML keys and map OIDC, console, and TLS settings to v3.8 env vars with updated docs and fixtures. --- docs/edgelet/control-plane.md | 14 +- docs/edgelet/examples/controlplane.yaml | 67 +++-- docs/edgelet/manifest-reference.md | 34 ++- docs/edgelet/modules/controlplane.md | 6 +- internal/controlplane/env.go | 120 +++++--- internal/controlplane/env_test.go | 167 ++++++++--- internal/controlplane/runtime.go | 18 +- internal/controlplane/runtime_test.go | 17 +- internal/models/control_plane_deployment.go | 38 +-- internal/models/control_plane_manifest.go | 276 +++++++++++++++--- .../models/control_plane_manifest_mask.go | 20 +- .../models/control_plane_manifest_test.go | 268 +++++++++++++++-- internal/store/control_plane_deployments.go | 26 +- .../store/control_plane_deployments_test.go | 26 ++ .../migrations/001_edgelet_schema_v1.sql | 2 + internal/store/schema_v1_contract_test.go | 2 + pkg/imageref/normalize.go | 42 +++ pkg/imageref/normalize_test.go | 17 ++ .../fixtures/controlplane-it.yaml | 30 +- test/deployment-yamls/controlplane.yaml | 76 +++-- 20 files changed, 1005 insertions(+), 261 deletions(-) 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/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/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/internal/controlplane/env.go b/internal/controlplane/env.go index 241f0ba..1310799 100644 --- a/internal/controlplane/env.go +++ b/internal/controlplane/env.go @@ -45,42 +45,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 +211,26 @@ 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) + intermediatePath := filepath.Join(path, models.ControlPlaneTLSCAFilename) + if _, err := os.Stat(intermediatePath); 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..8788f55 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, } 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..ae6dfd2 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,166 @@ 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 validateControlPlaneHTTPS(m.Spec.HTTPS) + 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") + } + 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 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 } -func validateControlPlaneHTTPS(https *ControlPlaneHTTPSConfig) error { - if https == nil { +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") + return errors.New("spec.tls.path must be an absolute host path") } info, err := os.Stat(path) if err != nil { - return fmt.Errorf("spec.https.path must exist on the Edgelet host: %w", err) + return fmt.Errorf("spec.tls.path must exist on the Edgelet host: %w", err) } if !info.IsDir() { - return errors.New("spec.https.path must be a directory") + return errors.New("spec.tls.path must be a directory") } - for _, file := range []string{ControlPlaneHTTPSCertFilename, ControlPlaneHTTPSKeyFilename} { + for _, file := range []string{ControlPlaneTLSCertFilename, ControlPlaneTLSKeyFilename} { if _, err := os.Stat(filepath.Join(path, file)); err != nil { - return fmt.Errorf("spec.https.path must contain %s: %w", file, err) + 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 +432,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..352f1af 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,29 @@ 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 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 +347,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/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/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/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/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 From b6fca0cef4b6db08bfd9cc9587cce0db2502f303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 02:24:49 +0300 Subject: [PATCH 02/10] Apply log level changes on config reload without restarting the service. Share one reload path for SIGHUP, config PATCH, and system reload, and improve CLI success output for edgelet system reload. --- cmd/edgelet-server/daemon.go | 38 +----- cmd/edgelet/daemon_desktop.go | 35 +---- internal/cli/cmd/system.go | 2 +- internal/cli/output/edgeletapi_mutations.go | 2 + internal/cli/output/format_test.go | 7 + internal/config/reload.go | 62 +++++++++ internal/config/reload_test.go | 93 ++++++++++++++ .../edgeletapi/handlers/config_reload_test.go | 121 ++++++++++++++++++ internal/supervisor/supervisor.go | 13 +- 9 files changed, 303 insertions(+), 70 deletions(-) create mode 100644 internal/config/reload.go create mode 100644 internal/config/reload_test.go create mode 100644 internal/edgeletapi/handlers/config_reload_test.go 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/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/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/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() From 4d50a58313427e1d22bf7573b1d10797c83a8b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 02:24:54 +0300 Subject: [PATCH 03/10] Support controller semver OTA and post-upgrade reprovision. Normalize flat and nested version responses, drive install.sh from semver, and rotate keys after upgrade or rollback via a pending reprovision file. --- internal/fieldagent/changes.go | 19 +- internal/fieldagent/ota_reprovision.go | 150 ++++++++++++++++ internal/fieldagent/ota_reprovision_test.go | 20 +++ internal/version/handler.go | 91 +++++++++- internal/version/handler_test.go | 120 ++++++++++++- internal/version/normalize_response.go | 80 +++++++++ internal/version/normalize_response_test.go | 184 ++++++++++++++++++++ internal/version/ota_reprovision.go | 171 ++++++++++++++++++ internal/version/release.go | 24 ++- 9 files changed, 847 insertions(+), 12 deletions(-) create mode 100644 internal/fieldagent/ota_reprovision.go create mode 100644 internal/fieldagent/ota_reprovision_test.go create mode 100644 internal/version/normalize_response.go create mode 100644 internal/version/normalize_response_test.go create mode 100644 internal/version/ota_reprovision.go 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/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/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..45b44d7 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"}) + expiry := 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": expiry, + "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 + } + + nearExpiry := time.Now().Add(2 * time.Minute).UnixMilli() + err := h.executeChangeVersionScript( + VersionCommandUpgrade, + map[string]any{ + "command": "UPGRADE", + "provisionKey": "stale-key", + "expirationTime": nearExpiry, + "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 +} From 8f4f1441e167ad9a88068e88b267883be4871ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 02:25:02 +0300 Subject: [PATCH 04/10] Register the controller microservice once and guard isController lifecycle. Call POST /api/v3/agent/controller/register after provision, skip PM add/delete for the controller UUID, and block destructive MS and controlplane operations when provisioned. --- docs/edgelet/deployment.md | 2 + internal/edgeletapi/handlers/controlplane.go | 4 + .../edgeletapi/handlers/controlplane_test.go | 13 +- internal/fieldagent/agent.go | 27 +- internal/fieldagent/api_client.go | 37 +- internal/fieldagent/api_client_test.go | 46 +++ internal/fieldagent/controller_reconcile.go | 152 ++++++++ .../fieldagent/controller_reconcile_test.go | 317 +++++++++++++++++ internal/fieldagent/controller_register.go | 334 ++++++++++++++++++ .../fieldagent/controller_register_test.go | 187 ++++++++++ internal/fieldagent/sync.go | 54 ++- internal/fieldagent/sync_ports_test.go | 61 ++++ internal/fieldagent/workers.go | 2 + .../processmanager/controlplane_managed.go | 24 ++ .../controlplane_managed_test.go | 36 ++ .../processmanager/controlplane_reconcile.go | 115 +++++- .../controlplane_reconcile_test.go | 180 +++++++++- internal/processmanager/manager.go | 43 ++- internal/runtimeapi/controlplane.go | 9 +- internal/runtimeapi/controlplane_ms.go | 29 +- internal/runtimeapi/controlplane_ms_test.go | 34 +- 21 files changed, 1647 insertions(+), 59 deletions(-) create mode 100644 internal/fieldagent/api_client_test.go create mode 100644 internal/fieldagent/controller_reconcile.go create mode 100644 internal/fieldagent/controller_reconcile_test.go create mode 100644 internal/fieldagent/controller_register.go create mode 100644 internal/fieldagent/controller_register_test.go create mode 100644 internal/fieldagent/sync_ports_test.go create mode 100644 internal/processmanager/controlplane_managed.go create mode 100644 internal/processmanager/controlplane_managed_test.go 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/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/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..4a6c343 --- /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/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/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{ From 19596764fa63a483cca387041aacaea03fb3ddd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 02:25:09 +0300 Subject: [PATCH 05/10] Embed uninstall in the install monolith and align paths across linux, darwin, and windows. Ship both scripts under the OS-specific share directory after curl-only install and document per-OS layout in installation docs. --- docs/edgelet/installation.md | 89 +- install.sh | 2709 +++++++++++++++-- internal/utils/constants.go | 20 +- packaging/PACKAGING-STRUCTURE.md | 2 +- scripts/assemble-install.sh | 95 +- .../gen-embedded-install-self-block.sh | 36 + .../install/gen-embedded-uninstall-block.sh | 24 + test/install/install-curl-pipe.sh | 10 + test/install/install-release-layout.sh | 5 + uninstall.sh | 119 +- 10 files changed, 2824 insertions(+), 285 deletions(-) create mode 100755 scripts/install/gen-embedded-install-self-block.sh create mode 100755 scripts/install/gen-embedded-uninstall-block.sh 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/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/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/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/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/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 "" From 9badd2ba961961c695c092955175559397c38d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 02:25:16 +0300 Subject: [PATCH 06/10] Update control-plane integration tests for v3.8 fixtures and register behavior. Align cp.sh helpers and embedded/docker suites with the new manifest schema and controller microservice guards. --- test/control-plane/README.md | 4 ++-- test/control-plane/lib/cp.sh | 25 ++++++++++++++++++++----- test/control-plane/run-all.sh | 2 +- test/control-plane/t12-docker.sh | 4 ++-- test/control-plane/t12-embedded.sh | 4 ++-- 5 files changed, 27 insertions(+), 12 deletions(-) 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/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}" From be1cec594024f2be7014b60d521a3b5f876e7a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 02:25:21 +0300 Subject: [PATCH 07/10] Consolidate CI into a single workflow and add v1.0.0-beta.3 release notes. Replace ci-go.yml with ci.yml, run unit tests in Linux Docker via make test-linux, and document beta.3 install, reload, OTA, and breaking CP YAML changes. --- .github/workflows/{ci-go.yml => ci.yml} | 0 CHANGELOG.md | 30 +++++++++++++++++++++++++ Makefile | 12 ++++++---- README.md | 15 +++++++++++-- internal/cgroups/detect_linux_test.go | 2 -- scripts/test-linux.sh | 27 ++++++++++++++++++++++ 6 files changed, 78 insertions(+), 8 deletions(-) rename .github/workflows/{ci-go.yml => ci.yml} (100%) create mode 100755 scripts/test-linux.sh 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/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/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)" From 3e877ae36f3c3c5e0e76596b5502cf7780db38dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 02:32:01 +0300 Subject: [PATCH 08/10] fmt format fixed --- .../fieldagent/controller_reconcile_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/fieldagent/controller_reconcile_test.go b/internal/fieldagent/controller_reconcile_test.go index 4a6c343..72085ea 100644 --- a/internal/fieldagent/controller_reconcile_test.go +++ b/internal/fieldagent/controller_reconcile_test.go @@ -133,9 +133,9 @@ func TestReconcileControllerMicroservice_IgnoresEquivalentDockerHubImageRef(t *t const uuid = "cp-reconcile-image-alias" cp := &models.ControlPlaneDeployment{ - ControllerUUID: uuid, - Namespace: "default", - Name: "pot", + ControllerUUID: uuid, + Namespace: "default", + Name: "pot", ManifestYAML: `apiVersion: edgelet.iofog.org/v1 kind: ControlPlane metadata: @@ -187,9 +187,9 @@ func TestReconcileControllerMicroservice_BumpsGenerationOnImageChange(t *testing const uuid = "cp-reconcile-image-change" cp := &models.ControlPlaneDeployment{ - ControllerUUID: uuid, - Namespace: "default", - Name: "pot", + ControllerUUID: uuid, + Namespace: "default", + Name: "pot", ManifestYAML: `apiVersion: edgelet.iofog.org/v1 kind: ControlPlane metadata: @@ -244,9 +244,9 @@ func TestReconcileControllerMicroservice_BumpsGenerationOnRegistryDrift(t *testi const uuid = "cp-reconcile-registry-drift" cp := &models.ControlPlaneDeployment{ - ControllerUUID: uuid, - Namespace: "default", - Name: "pot", + ControllerUUID: uuid, + Namespace: "default", + Name: "pot", ManifestYAML: `apiVersion: edgelet.iofog.org/v1 kind: ControlPlane metadata: From 34687313c2e6d70b78e25f5ca5e407798238a4b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 02:52:42 +0300 Subject: [PATCH 09/10] fix(windows): use ReloadFromDisk for config watcher reload Fixes undefined reloadAgentConfig breaking GOOS=windows CI build. --- cmd/edgelet/daemon_signal_windows.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 { From ac239ca0b9182570baafb87b0e7850f0d8bcd932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 03:02:45 +0300 Subject: [PATCH 10/10] test(version): suffix OTA expiry vars with Milli for epoch-naming Rename expiry and nearExpiry to expiryMilli and nearExpiryMilli so revive recognizes Unix-millisecond timestamps in handler tests. --- internal/controlplane/env.go | 4 +- internal/controlplane/runtime_test.go | 19 +++- internal/models/control_plane_manifest.go | 95 +++++++++++++++++-- .../models/control_plane_manifest_test.go | 89 +++++++++++++++++ internal/version/handler_test.go | 8 +- 5 files changed, 199 insertions(+), 16 deletions(-) diff --git a/internal/controlplane/env.go b/internal/controlplane/env.go index 1310799..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" @@ -218,8 +217,7 @@ func projectTLSEnv(env map[string]string, tls *models.ControlPlaneTLSConfig) { if path := strings.TrimSpace(tls.Path); path != "" { setEnv(env, "TLS_PATH_CERT", models.ControlPlaneTLSCertFilename) setEnv(env, "TLS_PATH_KEY", models.ControlPlaneTLSKeyFilename) - intermediatePath := filepath.Join(path, models.ControlPlaneTLSCAFilename) - if _, err := os.Stat(intermediatePath); err == nil { + 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)) } diff --git a/internal/controlplane/runtime_test.go b/internal/controlplane/runtime_test.go index 8788f55..1d6fc16 100644 --- a/internal/controlplane/runtime_test.go +++ b/internal/controlplane/runtime_test.go @@ -140,7 +140,7 @@ func TestBuildMicroserviceFromControlPlaneTLSPathMount(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" { @@ -148,6 +148,23 @@ func TestBuildMicroserviceFromControlPlaneTLSPathMount(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/models/control_plane_manifest.go b/internal/models/control_plane_manifest.go index ae6dfd2..76a5a0b 100644 --- a/internal/models/control_plane_manifest.go +++ b/internal/models/control_plane_manifest.go @@ -380,6 +380,84 @@ func validateControlPlaneAuthOptions(auth *ControlPlaneAuthSpec) error { 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 @@ -396,18 +474,19 @@ func validateControlPlaneTLS(tls *ControlPlaneTLSConfig) error { return nil } if hasPath { - if !isValidHostPath(path) { - return errors.New("spec.tls.path must be an absolute host path") - } - info, err := os.Stat(path) + cleaned, err := ValidateControlPlaneTLSPath(path) if err != nil { - return fmt.Errorf("spec.tls.path must exist on the Edgelet host: %w", err) + return err } - if !info.IsDir() { - return errors.New("spec.tls.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{ControlPlaneTLSCertFilename, ControlPlaneTLSKeyFilename} { - if _, err := os.Stat(filepath.Join(path, file)); err != nil { + if _, err := StatControlPlaneTLSFile(cleaned, file); err != nil { return fmt.Errorf("spec.tls.path must contain %s: %w", file, err) } } diff --git a/internal/models/control_plane_manifest_test.go b/internal/models/control_plane_manifest_test.go index 352f1af..9df5cbb 100644 --- a/internal/models/control_plane_manifest_test.go +++ b/internal/models/control_plane_manifest_test.go @@ -325,6 +325,95 @@ func TestControlPlaneManifestValidate_TLSPathRequiresAbsoluteExistingDir(t *test } } +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_TLSBase64RequiresValidEncoding(t *testing.T) { doc := validControlPlaneManifestForTest() doc.Spec.TLS = &ControlPlaneTLSConfig{ diff --git a/internal/version/handler_test.go b/internal/version/handler_test.go index 45b44d7..84b87c8 100644 --- a/internal/version/handler_test.go +++ b/internal/version/handler_test.go @@ -176,14 +176,14 @@ func TestExecuteChangeVersionScript_LaunchesDetachedInstallSh(t *testing.T) { } writeKV(t, filepath.Join(dir, "receipt"), map[string]string{"installed_version": "1.0.0"}) - expiry := time.Now().Add(20 * time.Minute).UnixMilli() + expiryMilli := time.Now().Add(20 * time.Minute).UnixMilli() err := h.executeChangeVersionScript( VersionCommandUpgrade, map[string]any{ "version": "v2.0.0", "semver": "2.0.0", "provisionKey": "audit-key", - "expirationTime": expiry, + "expirationTime": expiryMilli, "command": "UPGRADE", }, "audit-key", @@ -282,13 +282,13 @@ func TestExecuteChangeVersionScript_PreflightRefresh(t *testing.T) { }, nil } - nearExpiry := time.Now().Add(2 * time.Minute).UnixMilli() + nearExpiryMilli := time.Now().Add(2 * time.Minute).UnixMilli() err := h.executeChangeVersionScript( VersionCommandUpgrade, map[string]any{ "command": "UPGRADE", "provisionKey": "stale-key", - "expirationTime": nearExpiry, + "expirationTime": nearExpiryMilli, "semver": "2.0.0", }, "stale-key",