From patchwork Fri Jun 2 13:20:20 2023 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: "Storm, Christian" X-Patchwork-Id: 1789621 Return-Path: X-Original-To: incoming@patchwork.ozlabs.org Delivered-To: patchwork-incoming@legolas.ozlabs.org Authentication-Results: legolas.ozlabs.org; spf=pass (sender SPF authorized) smtp.mailfrom=googlegroups.com (client-ip=2a00:1450:4864:20::139; helo=mail-lf1-x139.google.com; envelope-from=swupdate+bncbdd6bwv65qpbb7wy46rqmgqehljajhq@googlegroups.com; receiver=) Authentication-Results: legolas.ozlabs.org; dkim=pass (2048-bit key; unprotected) header.d=googlegroups.com header.i=@googlegroups.com header.a=rsa-sha256 header.s=20221208 header.b=r0nMGyR+; dkim-atps=neutral Received: from mail-lf1-x139.google.com (mail-lf1-x139.google.com [IPv6:2a00:1450:4864:20::139]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature ECDSA (P-384) server-digest SHA384) (No client certificate requested) by legolas.ozlabs.org (Postfix) with ESMTPS id 4QXkBW0C7vz20Tj for ; Fri, 2 Jun 2023 23:20:02 +1000 (AEST) Received: by mail-lf1-x139.google.com with SMTP id 2adb3069b0e04-4f51dd0bf6asf1715034e87.3 for ; Fri, 02 Jun 2023 06:20:02 -0700 (PDT) ARC-Seal: i=3; a=rsa-sha256; t=1685711999; cv=pass; d=google.com; s=arc-20160816; b=cP8Eb1FiKP1tdO6bm+Z3taK2Pjntk8hJd2RIqnb8corLRA6gxIiP2GI7sDyst5WOD4 klla9uTv/6UgCKUAr3iu1IJvf4BTM3+dW7kmt2shpneEpsQsbs8UvfbxX3kVaqQ2tvW4 pmBqogg0b2PT1cuUPxZaXm5LmG/GUltP1rc9DxhIIuKwMUqdQys0+JST0SUZ04nzO0zl 6jSNswIOhQDg9D41CUM+HkpIqUJu2WKAxCgyDTfDex0yI1R1ewh1VaDuffszPNNw6gHD Z69nz1qco6c/DswMuSmI8Kuw29KOHo1syMVVD/YnVsWtD4CQe13NfjPcuEP7fYFvabX1 3ltg== ARC-Message-Signature: i=3; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816; h=list-unsubscribe:list-subscribe:list-archive:list-help:list-post :list-id:mailing-list:precedence:reply-to:mime-version :content-transfer-encoding:references:in-reply-to:message-id:date :subject:cc:to:from:dkim-signature; bh=BBaMdWV0zmeOr9wYjyMYbWx9FYrcSkwRnkBLh/19UKw=; b=cAB6uWjNj7X0gCfeMYJMxWdi/HBIrI7Qq2lZ+kgon8Iznfqy4xffspZyUgYqYntN5Q Jnu66Ok9Sk1+lbzbLT1wlPMzX7ilx7DKWhrxnx8sxei7k1WcPUMrG+L84TAnQTqlWMF2 XNksw0Qv9E2TnC7C99LBHsIFz2xQOfjIFxyTtcmGMGAzSMTWNf9ATgLTfz6F+kXjpRZB GaXBae1dFEmY0v1do7tcOdHHaSUprJInDO2VjwKiPxQUhOeZgpBCdG16pw49BDzICXFX Ohdjoml4lURJa+ssm5Fbx81fbi22xQuOAryy8KJ9GSRhNh70CdU9U1wkCl3LZB/MnDtC jXzA== ARC-Authentication-Results: i=3; gmr-mx.google.com; dkim=pass header.i=@siemens.com header.s=selector2 header.b=Dz+sD61A; arc=pass (i=1 spf=pass spfdomain=siemens.com dkim=pass dkdomain=siemens.com dmarc=pass fromdomain=siemens.com); spf=pass (google.com: domain of christian.storm@siemens.com designates 2a01:111:f400:fe1a::606 as permitted sender) smtp.mailfrom=christian.storm@siemens.com; dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=siemens.com DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=googlegroups.com; s=20221208; t=1685711999; x=1688303999; h=list-unsubscribe:list-subscribe:list-archive:list-help:list-post :list-id:mailing-list:precedence:reply-to :x-original-authentication-results:x-original-sender:mime-version :content-transfer-encoding:references:in-reply-to:message-id:date :subject:cc:to:from:from:to:cc:subject:date:message-id:reply-to; bh=BBaMdWV0zmeOr9wYjyMYbWx9FYrcSkwRnkBLh/19UKw=; b=r0nMGyR+lrd9wJ2mOLYeeSLCwAIwIWRO3dhNW3ICCwZbNbeoxE0rEFveclbltNYkX2 HYF53SGpprR24ZGpHJOQK1LplgFTCZI7kukkwmqlH4LH4CgNokuhn+aQLBU+vN2S2KQH XYijgmFnTWk+MXQ6T1s4ztzvj0KJz313xhZ2CYaRMWoyZi5gT+Y5yA2A7BCHgY+WcKN/ vm9VTbg+J0V8dfYJnfRcYpz4gPIwUbQVNd8XCdu5cx1Uf1w6LGWJRGspahqq2ZTJH8Jt Qz3xmw53kLNWeE5ABLgVtwq6F1m04BPuotV6GSWg4nmDtimlIk19ev8bftfGDzjEDSLq aSzg== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1685711999; x=1688303999; h=list-unsubscribe:list-subscribe:list-archive:list-help:list-post :x-spam-checked-in-group:list-id:mailing-list:precedence:reply-to :x-original-authentication-results:x-original-sender:mime-version :content-transfer-encoding:references:in-reply-to:message-id:date :subject:cc:to:from:x-beenthere:x-gm-message-state:from:to:cc :subject:date:message-id:reply-to; bh=BBaMdWV0zmeOr9wYjyMYbWx9FYrcSkwRnkBLh/19UKw=; b=GXgGh6Yqf3j4BJHmAH61nEh0/htXiwBwBg8TulysBUZ9xpc1p9bQWryof+ZF7iQevt 7A5WHC30QZ1P/hzfH8qh7qXSXrw9VGcRt0nttypEEf39TaE4j6xv4gNiaq9Tqku4y1nr 1Le11zYH7nShGzRqqB58Jd0wD3uUSj/ZOpgkyu8Rl66c+iTX5MjZjPiX3EX2sNO4isBJ c0AmAbjmbTW3GQ9exMFGRf62uvBRs0ABp20/BGoDZDC13uHV2OhCUgOmcUHd6E8+F8BI IgAnYTjsVLfgwBuCYALYVIsCEWa7xG6am9Vbpsztxec1gBWCeH5kxTCocNWSlnpIe+Zh zy0w== X-Gm-Message-State: AC+VfDwtpFNKKtR0+Dzs03SjfXZqw0BH3uqUjpV3Anr7J1XLKWjaMTFQ TeERcpp58zpJe69yAG+npPk= X-Google-Smtp-Source: ACHHUZ7snr/jhwpUutGpbGZpanHQtNZNtMCmiOwqdhhdbS7+A+QqyDjCmNLbMzdR49F4RxL6GxOf0A== X-Received: by 2002:ac2:5143:0:b0:4f1:43b9:a600 with SMTP id q3-20020ac25143000000b004f143b9a600mr1685599lfd.60.1685711998811; Fri, 02 Jun 2023 06:19:58 -0700 (PDT) X-BeenThere: swupdate@googlegroups.com Received: by 2002:ac2:4dbb:0:b0:4f3:b80e:fe54 with SMTP id h27-20020ac24dbb000000b004f3b80efe54ls323969lfe.1.-pod-prod-07-eu; Fri, 02 Jun 2023 06:19:57 -0700 (PDT) X-Received: by 2002:ac2:5392:0:b0:4dd:9ddc:4463 with SMTP id g18-20020ac25392000000b004dd9ddc4463mr1638232lfh.5.1685711996914; Fri, 02 Jun 2023 06:19:56 -0700 (PDT) ARC-Seal: i=2; a=rsa-sha256; t=1685711996; cv=pass; d=google.com; s=arc-20160816; b=F7LPOD01J+TURZ1cXZgt7MQWwbkN0js4mBVbdJ9c7nBOjAgs62DdLGR/bNWr/qJ6EC iF/688ltm3AW+y1aqremptJUGFAjsROdi7VPN+6/2qSniMYG7vpdhfggO/PLuIOWC8Wm QUhqggWXLOlQ+q75ec0WZB+I7esOs/nEbeCPePJLgmYFFdPOQTGGmQQ+EsErYey1uOfA fkjPaX6Kffcbt1JvY7WvIw1dF3sCX38er5tfSzyZ8Ho+8kWw1UuR2tQfMG765/hzecRT eyOSOvTjY+V1IU9CT9doaQ/HhqkTvV8nXJ0M+4mAzdkyWYKB335fhmPDM9OtyfgVVVpL IaNA== ARC-Message-Signature: i=2; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816; h=mime-version:content-transfer-encoding:references:in-reply-to :message-id:date:subject:cc:to:from:dkim-signature; bh=YglhNzs1nWXpwwmwRkXz/JRzz9JsQ2D6VVELWag1z7E=; b=XHmnaU++pR6x+DN9nW4GUmsKY0OF14lH7tAFykWD6A5DxBL8g3vcSpeVEq4bf2beVi qnr3z0V+5QTRw8vnKqvjhD9CaTAnzsdixVP3NKSD8VoOEtw5RCCM/qEtu+cRKrHJsywm 8Jsz8Ivsyap1eQE+LK3DrYkVMRuIlarx2e77i/GRiwAXzE1urEg22zwcREm0rE87Zo+3 0riANbCM48RNCcDW6O25I19QyIy9n4+X4DEUmP17hJltVZZIuOHUS7QKyXTUj6kDH8fK UIFSQJuoZ/MNcBtMpo8ssG5NS4Hrfe1lLB4eTMZJ8KQfikGni/g4mtC88Czyw+0l/3iq UXpQ== ARC-Authentication-Results: i=2; gmr-mx.google.com; dkim=pass header.i=@siemens.com header.s=selector2 header.b=Dz+sD61A; arc=pass (i=1 spf=pass spfdomain=siemens.com dkim=pass dkdomain=siemens.com dmarc=pass fromdomain=siemens.com); spf=pass (google.com: domain of christian.storm@siemens.com designates 2a01:111:f400:fe1a::606 as permitted sender) smtp.mailfrom=christian.storm@siemens.com; dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=siemens.com Received: from EUR03-DBA-obe.outbound.protection.outlook.com (mail-dbaeur03on20606.outbound.protection.outlook.com. [2a01:111:f400:fe1a::606]) by gmr-mx.google.com with ESMTPS id p16-20020a056512235000b004f4d38a01a4si92159lfu.0.2023.06.02.06.19.56 for (version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256 bits=128/128); Fri, 02 Jun 2023 06:19:56 -0700 (PDT) Received-SPF: pass (google.com: domain of christian.storm@siemens.com designates 2a01:111:f400:fe1a::606 as permitted sender) client-ip=2a01:111:f400:fe1a::606; ARC-Seal: i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none; b=HzPrQG3d/TCzwZYIUwNlB8rBqiDFIr2eOSazZcHf5qU5E0w/TeREGyLI2+BL0LOvoAGi7fWn14b/KsmJkcKgBPpSFZuNklKE7B11ZNnyg7Cd0QyXOUTD89NolMv8T+xBAkyg4NxMwCk1+R5DbZVNT31uPGsmzs57hvnLgx1kAKaIeP/0CzX6lxSx+a+o2RCAQfzyUah650DsiVsJ2CIT2AGzevYd3EV2VclOnd4NG59rzwLvhh77utoFuFl+vDiAZNkfU6dR6eOWnVjCMC75lcioZa/4JSwkno8+CRjwQAM/GY2nXGhxbDEwRNpY2QICeKOWPdibsqetAD1y+pHeeg== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com; s=arcselector9901; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1; bh=YglhNzs1nWXpwwmwRkXz/JRzz9JsQ2D6VVELWag1z7E=; b=MRRk1TX39ujGjcCW1KwvWGKHX2JGCWqBttvVLBPUyGxRjAyLcEpnDsu31snSABH/5bx/bbWl/rEk4EzF9oMONW5V5lWKJs/0WusfkLOGEwEFK134tYjX2UomP8wlBGhEQLfi6APdiJRELcZNhGgYk5x5Fb4Md43bmRAQvRbBZn02pSTIazwMdxuHYNxSr+kyAqCHm3UehXD4ycuZRjO2JCk3A6OLEsBWfnel5+EVNggfFoTwOd5Gh5XytBn42kB0s3Zi4r+5Y2prc/iQ/GsW1QMQIm/XyLFaLtD6++puoLHRVNz0S9MfXI+8N5QspNHNOuYiPgBBG3NIk17g/IlTRA== ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=pass smtp.mailfrom=siemens.com; dmarc=pass action=none header.from=siemens.com; dkim=pass header.d=siemens.com; arc=none Received: from DB9PR10MB5404.EURPRD10.PROD.OUTLOOK.COM (2603:10a6:10:333::17) by GVXPR10MB5887.EURPRD10.PROD.OUTLOOK.COM (2603:10a6:150:6::15) with Microsoft SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.6455.23; Fri, 2 Jun 2023 13:19:53 +0000 Received: from DB9PR10MB5404.EURPRD10.PROD.OUTLOOK.COM ([fe80::6bed:3b93:4756:c9f3]) by DB9PR10MB5404.EURPRD10.PROD.OUTLOOK.COM ([fe80::6bed:3b93:4756:c9f3%6]) with mapi id 15.20.6433.025; Fri, 2 Jun 2023 13:19:53 +0000 X-Patchwork-Original-From: "'Christian Storm' via swupdate" From: "Storm, Christian" To: swupdate@googlegroups.com Cc: Christian Storm Subject: [swupdate] [PATCH 3/5] suricatta/wfx: Add support for wfx server Date: Fri, 2 Jun 2023 15:20:20 +0200 Message-Id: <20230602132022.70931-3-christian.storm@siemens.com> X-Mailer: git-send-email 2.40.1 In-Reply-To: <20230602132022.70931-1-christian.storm@siemens.com> References: <20230602132022.70931-1-christian.storm@siemens.com> X-ClientProxiedBy: FR0P281CA0057.DEUP281.PROD.OUTLOOK.COM (2603:10a6:d10:49::14) To DB9PR10MB5404.EURPRD10.PROD.OUTLOOK.COM (2603:10a6:10:333::17) MIME-Version: 1.0 X-MS-PublicTrafficType: Email X-MS-TrafficTypeDiagnostic: DB9PR10MB5404:EE_|GVXPR10MB5887:EE_ X-MS-Office365-Filtering-Correlation-Id: a0133405-f75b-4377-7047-08db636c0ccf X-MS-Exchange-SenderADCheck: 1 X-MS-Exchange-AntiSpam-Relay: 0 X-Microsoft-Antispam: BCL:0; X-Microsoft-Antispam-Message-Info: GQWLuNSBUrcsCiefGDcDINRshjnB+ug2VYYgMCVu+WDDkWlx0nfsYhhiQOQLjpL4ayjmbYpuUFjhzMVVs4CK+bB/S2t9JpAg9VAl8RENry4/8rLfSvkDO9d7BIByKmgnJFHTzIu1BYSizxmSrlTF2VnXAegNgngRhENHvBP3mBDZjmeVTP62NtkGC24zNcS72vg0rqZpfX3I9OCWFQqbcts5tKq67j/ZdjV4rbdF0VHrRP+pLU2bmqdMLwvcqjMm8g8XiSvxS6DHDHStvRDmxHaPxWIXkQzI0bwv/azSlMFKKSIQv8VaN0d6JF9tSPma26t2KMBwn6/w+m5CzAdK6X1x2S4LUrVmGZmFCMKMebQpUAjkRICPrNoIdZx0xPfkhQ8TN6WJNk00zsxjD0lpNY3YTGgqp9cBzzAEntzM3yaj+2Td2pgLBnYUdFp/nTcf+tWkwpxjaQNvRD7+hq06cg5UBBAIsbGfxX6ch8cP5hBFn4fKaumM7OFamqvpBoi+uvAZpIZS4yTYZdKOnVqGutPyEgM5O4l/ZCdkzcMCoCk= X-Forefront-Antispam-Report: CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:DB9PR10MB5404.EURPRD10.PROD.OUTLOOK.COM;PTR:;CAT:NONE;SFS:(13230028)(4636009)(39860400002)(376002)(136003)(346002)(366004)(396003)(451199021)(966005)(6486002)(478600001)(6666004)(83380400001)(186003)(36756003)(6512007)(2616005)(38100700002)(6506007)(86362001)(82960400001)(107886003)(1076003)(316002)(4326008)(41300700001)(66476007)(6916009)(66556008)(66946007)(44832011)(5660300002)(30864003)(8676002)(8936002)(2906002)(579004)(559001);DIR:OUT;SFP:1101; X-MS-Exchange-AntiSpam-MessageData-ChunkCount: 1 X-MS-Exchange-AntiSpam-MessageData-0: CnQTp57ISeCnsgXL7KRky8eGMkPhMqsKzzTDVItqdHnCSYQt4HkT/dy+ct25myqcjrUyHeq8tW1g5CGxkauXL0rd5HLm42WLBYwsIH3yojEv1YstBqJ/A1k8OT/GjxfvKAKn1WDoBOOVS5rMfB6Xyyb7IBOfbj0hAT9l2RQfLHQ0gSlQvz3bP+fy1prApnPH/MVfLQO/ojlKjehdOAuumq0FPBpisEU6PLgA6NHpXFPwaqQQ0byjCxjcPRZD5tlz7abqMqX2i7hNRRZkVx8PdAkpP5/JrBPQgc2RAcGQfyT5DLXFHI4u9MNCBDHHIuvUDx6X1Szfr7nrtimWnxXMSyimJ6nzL6DiTU6wPsmCcNtT7hU7zWYYEQF/BhbLt31cyDdAxbc1P+CG4P/y9lkTv20pbRL0RxV4Fm0kJP06+rAOxCRnm4+/X+R0yFabBs0qowVTSboJ8ULSlYkpFC/GI2cmdrI04AbID3HZWzNt1hD8lqvPb40oav1M9cz11pbXdkuKkyb/5T9UKfE5CNJYeZ2TfwQCZ/5MHRxbN7P8e//NODJr6LRr+popYOqpzOpW8VpVT5sMHxVzWQJJ+loI/rrVx1PkgWlqJ291BwO7qRwqT+4xXs1W5UscUEaC9LiFnicejEF8r1lrWall9YjPs2m5OL/msUyEc+e+2Vrftzh8zV460xH5Rzv/xT1eSb8RcFWslsJwiF9i6HwqhECQ1XZeCzLssHrdN/qLAtzr4jLOgZLwyJPvwh5OnYZjfnCmhZWilCYl+EV86W85i1Gz9Xb/ggs6slD5nru7qm/4p0+Hig+FbFZF0c6g0fdA0QAGQ921EeDw7cW6t/5HHuISE0yHlbx+bmoTREljcTZaSxQkk1um3ccoysHtmGkcLhE0rSDyaxjkk5G2PyH0bDknAE4OsqT43yaUaoxSGtpQyBkcYbNBoAkCTak4gBq/qrYratp0WyNNKbHeXj+sPMbvi4f2kP+wMOJhk3JaZEpTKupF0LGSmmeXBJ0MieUJWgUQZdYulkWAFSkwRbuJ8d+Lqs6cPLNvlYvWRzt7vHsJi82pt6h6hlCkdOH8lGStmCRZKVj3p3ijQiVXS3Sv7uc5j6a2SFXOk4039izKkP3ud3GxKN1H8KUip1yBWxBp8XeW+e/4I9relZU9pPkaAV2svUlwJwPmvLWbPmorHT1KkMR9V2kfDLnJyHT83My+Ut+94N+wqgzqD++oA8WfO+rkhnO8+MKB7zvuz/OY09hFvM6B/Tdi6c2nWV82zk9L/TKsJ8zvjt7l4sHHa3X3JHokYGIx/25zJsrymwz59y3AWbwcaTr1ofnUWLcDl6dP29EJPzyMdSHguqyZLoQ0iLJ7+cY9CK2hrYDSITwOXYQ5ElKN8Mw3cbomprSvTYIb7o3qVWD/VLYK0L4HIHxeQ28LwjfsRkf3vu3cBhpCFmFVkxr9xMDwpLg9syurjOlgRZD7YTsaz4dMXygf3atujDb5qqIzZQV8zxcLjXS1Vek+cJgBLoe9MZjdGPRv98vUQ70K0l0laFD0yqGbek5xidvb3cpZak52ecTaxMG5hwPlTRFW4lt0FwNrSBoUbB101E0KRNuCUH3nx/aiL534IF9ftCWJ8xtIjNI18MZtGtNrJyeF6H4NmG1U6brEjxhGZ+WLc8bfOZGgZEVLX0iXE7cDuA== X-OriginatorOrg: siemens.com X-MS-Exchange-CrossTenant-Network-Message-Id: a0133405-f75b-4377-7047-08db636c0ccf X-MS-Exchange-CrossTenant-AuthSource: DB9PR10MB5404.EURPRD10.PROD.OUTLOOK.COM X-MS-Exchange-CrossTenant-AuthAs: Internal X-MS-Exchange-CrossTenant-OriginalArrivalTime: 02 Jun 2023 13:19:53.2636 (UTC) X-MS-Exchange-CrossTenant-FromEntityHeader: Hosted X-MS-Exchange-CrossTenant-Id: 38ae3bcd-9579-4fd4-adda-b42e1495d55a X-MS-Exchange-CrossTenant-MailboxType: HOSTED X-MS-Exchange-CrossTenant-UserPrincipalName: CnwopCgkqRvq/Z6npXJ2EN56RJ7LjTCFM5EnBzI+B19ByYJizuc240Lu+tWUXIbspEiOtA9ZxRt+1OrYMU/Oa1l9j3BDNz22fyCA7dCfYz4= X-MS-Exchange-Transport-CrossTenantHeadersStamped: GVXPR10MB5887 X-Original-Sender: christian.storm@siemens.com X-Original-Authentication-Results: gmr-mx.google.com; dkim=pass header.i=@siemens.com header.s=selector2 header.b=Dz+sD61A; arc=pass (i=1 spf=pass spfdomain=siemens.com dkim=pass dkdomain=siemens.com dmarc=pass fromdomain=siemens.com); spf=pass (google.com: domain of christian.storm@siemens.com designates 2a01:111:f400:fe1a::606 as permitted sender) smtp.mailfrom=christian.storm@siemens.com; dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=siemens.com X-Original-From: Christian Storm Reply-To: Christian Storm Precedence: list Mailing-list: list swupdate@googlegroups.com; contact swupdate+owners@googlegroups.com List-ID: X-Spam-Checked-In-Group: swupdate@googlegroups.com X-Google-Group-Id: 605343134186 List-Post: , List-Help: , List-Archive: , List-Unsubscribe: , Add Support for wfx server (https://www.github.com/siemens/wfx) Signed-off-by: Christian Storm --- parser/Config.in | 2 +- suricatta/Config.in | 26 +- suricatta/server_wfx.lua | 2195 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 2215 insertions(+), 8 deletions(-) create mode 100644 suricatta/server_wfx.lua diff --git a/parser/Config.in b/parser/Config.in index d5444b8c..454e9cdb 100644 --- a/parser/Config.in +++ b/parser/Config.in @@ -29,7 +29,7 @@ config JSON depends on HAVE_JSON_C help Use json-c to parse the configuration file. - Also required for suricatta's hawkBit Server support. + Also required for suricatta server support. comment "JSON config parser support needs json-c" depends on !HAVE_JSON_C diff --git a/suricatta/Config.in b/suricatta/Config.in index 0fde4401..08362482 100644 --- a/suricatta/Config.in +++ b/suricatta/Config.in @@ -49,10 +49,11 @@ comment "hawkBit support needs json-c" config SURICATTA_LUA bool "Suricatta Lua module" - depends on HAVE_LUA depends on HAVE_JSON_C - select LUA + depends on HAVE_LUA + select CHANNEL_CURL select JSON + select LUA help Support for Suricatta modules in Lua. @@ -61,13 +62,17 @@ config SURICATTA_LUA enables channel result parsing to JSON per default. To enable, select 'libjson' in 'Parser Features'. -comment "Suricatta Lua module support needs Lua and json-c" - depends on !HAVE_LUA || !HAVE_JSON_C +config SURICATTA_WFX + bool "wfx support" + depends on SURICATTA_LUA + select EMBEDDED_SURICATTA_LUA + help + Support for wfx server. + https://www.github.com/siemens/wfx config EMBEDDED_SURICATTA_LUA bool "Embed Suricatta Lua module in SWUpdate binary" depends on SURICATTA_LUA - default n help Embed the Suricatta Lua module source code file into the SWUpdate binary. @@ -83,7 +88,8 @@ config EMBEDDED_SURICATTA_LUA config EMBEDDED_SURICATTA_LUA_SOURCE string "Suricatta Lua module file" depends on EMBEDDED_SURICATTA_LUA - default "swupdate_suricatta.lua" + default "swupdate_suricatta.lua" if !SURICATTA_WFX + default "suricatta/server_wfx.lua" if SURICATTA_WFX help Path to the Suricatta Lua module source code file to be embedded into the SWUpdate binary. @@ -102,7 +108,13 @@ comment "General HTTP support needs json-c" endmenu -endif +comment "hawkBit & Suricatta Lua module support needs json-c" + depends on !HAVE_JSON_C + +comment "Suricatta Lua module support needs Lua" + depends on !HAVE_LUA comment "Suricatta daemon support needs libcurl" depends on !HAVE_LIBCURL + +endif diff --git a/suricatta/server_wfx.lua b/suricatta/server_wfx.lua new file mode 100644 index 00000000..e34c3f6f --- /dev/null +++ b/suricatta/server_wfx.lua @@ -0,0 +1,2195 @@ +--[[ + + wfx SWUpdate Suricatta Lua Module. + + Author: Christian Storm + Copyright ©️ 2023, Siemens AG + + SPDX-License-Identifier: GPL-2.0-or-later + +--]] + +-- luacheck: no max line length +-- luacheck: no global + +suricatta = require("suricatta") + +-- Cater for table.unpack() on Lua 5.1 / LuaJIT +if not table.pack then + ---@diagnostic disable-next-line: duplicate-set-field + table.pack = function(...) + return { n = select("#", ...), ... } + end + ---@diagnostic disable-next-line: deprecated + table.unpack = unpack +end + +--- Exported wfx SWUpdate Suricatta Lua Module. +local M = { + --- Utility / Library Functions. + utils = { + --- Table Utility Functions. + table = {}, + --- String Utility Functions. + string = {}, + }, + --- DeviceArtifactUpdate Definitions and Functions. + dau = {}, + --- Suricatta Interface Function Implementations. + suricatta_funcs = {}, +} + +-- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +-- ▏ +-- ▏ Utility / Library Functions +-- ▏ +-- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +--- @enum GETOPT_ARG +--- getopt() argument specifier enum. +M.utils.GETOPT_ARG = { + NO = 0, + REQUIRED = 1, + [0] = "", + [1] = ":", +} + +--- Simplistic getopt(3)-alike. +-- +-- The following getopt(3) features are not supported: +-- * multiple identical short option arguments (e.g. `-v -v`), +-- * optional arguments, e.g., `-v [level]`, +-- * non-option arguments. +-- Unknown options given are (silently) ignored. +-- +-- The `opts` option table has the following format: +-- { +-- {"", GETOPT_ARG.{NO,REQUIRED}, "" }, +-- ... +-- } +-- `` may be `nil` in which case only `` is recognized. +-- +--- @param argv table Integer-keyed arguments Table, modified in-place. +--- @param opts table Option Table +--- @return function # Iterator, returning the next (option, optarg) pair +function M.utils.getopt(argv, opts) + local optmap = {} + local optstring = "" + for _, optdef in ipairs(opts) do + assert(optdef[3], "Mandatory short option char absent!") + assert(optdef[2], "Mandatory has_arg definition absent!") + optstring = ("%s%s%s"):format(optstring, optdef[3], M.utils.GETOPT_ARG[optdef[2]]) + optmap[optdef[1] or "_"] = optdef[3] + end + for index, value in ipairs(argv) do + if value:sub(1, 2) == "--" and optmap[value:sub(3)] then + local optvalue = "-" .. optmap[value:sub(3)] + argv[index] = optvalue + argv[optvalue] = index + elseif value:sub(1, 1) == "-" then + argv[value] = index + end + end + local f = optstring:gmatch("%a:?") + return function() + while true do + local option = f() + if not option then + return + end + local optchar = option:sub(1, 1) + local param = "-" .. optchar + if argv[param] then + if option:sub(2, 2) ~= ":" then + return optchar, nil + end + local value = argv[argv[param] + 1] + if not value then + return ":", optchar + end + if value:sub(1, 1) == "-" then + return ":", optchar + end + return optchar, value + end + end + end +end + +--- Reverse ipairs() iterator. +-- +-- Like Lua's internal `ipairs()`, just reverse. +-- +--- @param tab table Table to iterate over in reverse ipairs()-order +--- @return function # The iterator function +--- @return table # The table `tab` +--- @return number # Array index +function M.utils.ripairs(tab) + local function rev_iter_ipairs(t, i) + i = i - 1 + if i ~= 0 then + return i, t[i] + end + end + return rev_iter_ipairs, tab, #tab + 1 +end + +--- ipairs() + filter iterator. +-- +--- @param tab table Table to iterate over +--- @param fun fun(any):boolean Filter function; evaluates to `true` to filter out item +--- @return function # The iterator function +--- @return table # The table tab +--- @return number # Array index +function M.utils.ipairsf(tab, fun) + local function iter_ipairs(t, i) + while true do + i = i + 1 + local v = t[i] + if v == nil then + return + end + if not fun(v) then + return i, v + end + end + end + return iter_ipairs, tab, 0 +end + +--- Merge two Key=Value Tables, leftwards. +-- +--- @param tab table Destination Table, modified +--- @param ovr table Table to merge into `tab`, overruling `tab`'s data if existing in `ovr` +--- @return table # Merged Table, i.e., `tab` +function M.utils.table.merge(tab, ovr) + local function istable(t) + return type(t) == "table" + end + for k, v in pairs(ovr) do + if istable(v) and istable(tab[k]) then + M.utils.table.merge(tab[k], v) + else + tab[k] = v + end + end + return tab +end + +--- Check if a Table contains `value`. +-- +--- @param tab table Table to check for `value` +--- @param value any Value to check for presence in `tab` +--- @return boolean # `true` if `tab` contains `value`, `false` otherwise +function M.utils.table.contains(tab, value) + if tab[value] then + return true + end + for _, v in ipairs(tab) do + if v == value then + return true + end + end + return false +end + +--- Filter Array Table. +--- @param tbl table Table to filter +--- @param func fun(any):boolean Filter function; evaluates to `true` to filter out item +--- @return table # New table, filtered +function M.utils.table.ifilter(tbl, func) + local newtbl = {} + for _, v in ipairs(tbl or {}) do + if not func(v) then + newtbl[#newtbl + 1] = v + end + end + return newtbl +end + +--- Test if Table is empty. +-- +--- @param tbl table Table to test for emptiness +--- @return boolean # `true` if empty, `false` if not +function M.utils.table.is_empty(tbl) + local index, _ = next(tbl) + return not index and true or false +end + +--- Enquote a String array's elements. +-- +--- @param t string[] String array to quote elements of +--- @return string[] # Element-quoted string array +function M.utils.string.enquote(t) + local quoted = {} + for _, v in ipairs(t) do + quoted[#quoted + 1] = ('"%s"'):format(v) + end + return quoted +end + +--- Escape and trim a String. +-- +-- The default substitutions table is suitable for escaping +-- to proper JSON. +-- +--- @param str string The JSON string to be escaped +--- @param substs? table Substitutions to apply (optional) +--- @return string # The escaped JSON string +function M.utils.string.escape(str, substs) + local escapes = '[%z\1-\31"\\]' + if not substs then + substs = { + ['"'] = '"', + ["\\"] = "\\\\", + ["\b"] = "", + ["\f"] = "", + ["\n"] = "", + ["\r"] = "", + ["\t"] = "", + } + end + substs.__index = function(_, c) + return ("\\u00%02X"):format(c:byte()) + end + setmetatable(substs, substs) + return str:gsub(escapes, substs):match("^%s*(.-)%s*$"):gsub("%s+", " ") +end + +--- Execute a callback with retries and pause in between. +-- +-- The `callback` function must return `true` or, in case of error, +-- `false` to attempt the next try (if any left). +-- +--- @param times number Maximal number of tries +--- @param pause number Pause in seconds between tries (defaults to 1) +--- @param callback fun(...):boolean, ... The callback function to try +--- @param ... any The callback function's parameters +--- @return boolean # `true`, or, in case of error, `false` +--- @return ... # Return values of `callback(...)` +function M.utils.do_retry(times, pause, callback, ...) + local ret + times, pause = times or 1, pause or 1 + for try = 1, times do + ret = table.pack(callback(...)) + if ret[1] == true then + return table.unpack(ret) + end + suricatta.notify.warn("Retry attempt %d/%d failed, sleeping %s seconds.", try, times, pause) + suricatta.sleep(pause) + end + suricatta.notify.error("All %d retry attempts failed.", times) + return table.unpack(ret) +end + +--- Validate a JSON document against its schema. +-- +-- A schema for a JSON document is defined in terms of Lua code, +-- given in the `schema` parameter, and validated against this. +-- +-- The schema definition for a particular JSON key `jkey` is a Lua +-- Table specifying the `type` of `jkey`'s value, possibly extended +-- with `min` and `max` properties the value has to satisfy. +-- The latter properties are `type`-dependent: A property `max = 23` +-- for a number-typed `jkey` enforces its value to be less than or +-- equal to 23. For a string-typed `jkey`, a `max = 23` property +-- enforces the maximal string length to be less or equal to 23. +-- The `type` is simply given by an accordingly typed Lua value. +-- Per default, all keys (and their values) present in the schema +-- have to be present in the to be validated JSON document while +-- not necessarily vice versa. Optional schema JSON keys can be +-- flagged as such by the `optional = true` property. +-- *Note*: Any other extra fields (properties) in the JSON schema +-- are silently ignored. +-- +-- For example, +-- `jkey = { type = "string", min = 1 }` +-- specifies `jkey` to be a string with length > 1. +-- +-- As another example, +-- `jkey = { type = 100, max = 100, min = 0 }` +-- specifies `jkey` to be a number in [0,100]. +-- +-- This naturally extends to JSON arrays with its content schema +-- specified in the sole property `value` as follows +-- ``` +-- jkey = { +-- type = {}, +-- value = { +-- [1] = { +-- jkey = { type = "string", min = 1 } +-- }, +-- }, +-- } +-- ``` +-- specifying a non-empty JSON array `jkey` with its array contents +-- being strings of minimal length 1. +-- +--- @param data table Data to validate against a schema +--- @param schema table Schema table +--- @param path? string Initial path +--- @return boolean # `true` if `data` satisfies schema, `false` otherwise +function M.utils.validate_json_schema(data, schema, path) + local function check_types(def, k, v, p) + if not def[k] then + if v.optional == true then + if type(v.type) == "string" then + def[k] = "" + end + if type(v.type) == "number" then + def[k] = 0 + end + return true + end + suricatta.notify.error("Validation Error: JSON key '%s/%s' (%s) is missing.", p, k, type(v.type)) + return false + end + if type(def[k]) ~= type(v.type) then + suricatta.notify.error( + "Validation Error: JSON key '%s/%s' has wrong type (is %s != %s).", + p, + k, + type(def[k]), + type(v.type) + ) + return false + end + if type(def[k]) == "string" then + local slen = #def[k] + if v.min and slen < v.min then + suricatta.notify.error("Validation Error: JSON key '%s/%s' < %s", p, k, tostring(v.min)) + return false + end + if not v.min and slen == 0 then + suricatta.notify.error("Validation Error: JSON key '%s/%s' is empty.", p, k) + return false + end + if v.max and slen > v.max then + suricatta.notify.error("Validation Error: JSON key '%s/%s' > %s", p, k, tostring(v.max)) + return false + end + end + if type(def[k]) == "number" then + if v.min and def[k] < v.min then + suricatta.notify.error("Validation Error: JSON key '%s/%s' < %s", p, k, tostring(v.min)) + return false + end + if v.max and def[k] > v.max then + suricatta.notify.error("Validation Error: JSON key '%s/%s' > %s", p, k, tostring(v.max)) + return false + end + end + if type(v.type) == "table" then + if not M.utils.validate_json_schema(def[k], v.value, ("%s/%s"):format(p, k)) then + return false + end + end + return true + end + path = path or "" + if type(schema) ~= "table" then + suricatta.notify.error("Validation Error: Schema at '%s' is not Table.", path) + return false + end + for k, v in pairs(schema) do + if not check_types(data, k, v, path) then + return false + end + end + if schema[1] then + for i, _ in ipairs(data) do + if not check_types(data, i, schema[1], path) then + return false + end + end + end + return true +end + +-- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +-- ▏ +-- ▏ wfx Job Definitions & Types +-- ▏ +-- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +--- @class state +-- +-- Enum definitions for states. +-- +M.state = { + --- @enum state.types + --- Type of a state, used to classify state semantics. + types = { + --- A regular (i.e. none of the other) state of the workflow + REGULAR = "REGULAR", + --- A successful terminal state of the workflow + CLOSED = "CLOSED", + --- A failed terminal state of the workflow + FAILED = "FAILED", + }, +} + +--- @class transition +-- +-- Enum definitions for transitions. +-- +M.transition = { + --- @enum transition.eligibles + --- wfx's `EligibleEnum` enum values. + eligibles = { + --- CLIENT-eligible transition + CLIENT = "CLIENT", + --- WFX-eligible transition + WFX = "WFX", + }, + --- @enum transition.result + --- Transition execution function result enum values. + result = { + --- Transition execution function completed successfully, yield to wfx + COMPLETED = "COMPLETED", + --- Transition execution function failed, try next + FAIL_NEXT = "FAIL_NEXT", + --- Transition execution function failed, yield to wfx + FAIL_YIELD = "FAIL_YIELD", + }, +} + +--- Functionality-augmented wfx `Job` instance. +-- +--- @class job +--- @field id string Unique job ID +--- @field clientId string Device ID +--- @field definition job.definition DeviceArtifactUpdate definition +--- @field history any Job history (unused) +--- @field mtime string Date and time (ISO8601) when the job was last modified +--- @field stime string Date and time (ISO8601) when the job was created +--- @field status job.status Job Status Information +--- @field tags string[] Job tags +--- @field workflow job.workflow Job's Workflow +M.job = setmetatable({}, { + --- @param self job This job instance + --- @param wfx_job job wfx job + --- @return job? # This job instance with job data from wfx or `nil` on error + __call = function(self, wfx_job) + local function mt(tab, indextab) + local t = getmetatable(tab) or {} + t.__index = indextab + t.__newindex = function(_, key, value) + if not indextab[key] then + error( + ("Internal Error: Contaminating job Table with %s=%s"):format( + tostring(key or ""), + tostring(value or "") + ) + ) + end + rawset(indextab, key, value) + end + return setmetatable(tab, t) + end + + self.definition.meta = self + self.status.meta = self + self.workflow.meta = self + + M.job.status.normalize(wfx_job.status) + self = mt(self, wfx_job) + self.definition = mt(self.definition, wfx_job.definition) + self.status = mt(self.status, wfx_job.status) + self.workflow = mt(self.workflow, wfx_job.workflow) + if not self:validate() or not self:prepare() then + return + end + return self + end, +}) + +--- Validate the job. +-- +--- @return boolean # `true` if the job is valid, `false` otherwise +function M.job:validate() + local function validate_id() + if self.clientId ~= M.device.id then + suricatta.notify.error( + "Validation Error: Job's client ID (%s) doesn't match ours (%s).", + self.clientId, + M.device.id + ) + return false + end + return true + end + return validate_id() and self.definition:validate() and self.workflow:validate() +end + +--- Prepare the job. +-- +--- @return boolean # `true` if preparation succeeded, `false` otherwise +function M.job:prepare() + return self.definition:prepare() and self.workflow:prepare() +end + +--- Mark the job as invalid. +function M.job:invalidate() + if self.id then + self.id = "0xDEAD1D" + end +end + +--- Test whether the job is invalid. +-- +--- @return boolean # `true` if the job is invalid, `false` otherwise +function M.job:invalid() + return not self.id or self.id == "0xDEAD1D" +end + +-- ┌─────────────────────────────────────────────────────────────────────────── +-- │ Job Status +-- └─────────────────────────────────────────────────────────────────────────── + +--- Job status information. +-- +--- @class job.status +--- @field state string Name of the Workflow state +--- @field clientId string Client which sent the status update +--- @field progress number Current job progress percentage [0,100] +--- @field message string State update reason message +--- @field context string[] Additional context information. +--- @field definitionHash string `job.definition` hash +--- @field meta job This job status' `job` +M.job.status = { + --- Job status message JSON Schema. + -- + --- @class job.status.json_schema + -- + -- JSON schema to comply to when sending status updates to wfx. + -- + json_schema = { + clientId = { type = "string", min = 1 }, + state = { type = "string", min = 1 }, + progress = { type = 100, max = 100, min = 0 }, + message = { type = "string", max = 1024, min = 0 }, + context = { type = "string", max = 2000, delimiter = ", " }, + }, +} +setmetatable(M.job.status, { + __tostring = function(self) + local context = ([[{ "lines": [%s] }]]):format( + table.concat(M.utils.string.enquote(self.context or {}), self.json_schema.context.delimiter) + ) + if + not M.utils.validate_json_schema({ + state = self.state, + clientId = M.device.id, + progress = self.progress, + message = self.message, + context = context, + }, self.json_schema) + then + error("Internal Error: Status message is schema-invalid.") + end + local status = { + ([["state": "%s"]]):format(self.state), + ([["clientId": "%s"]]):format(M.device.id), + ([["progress": %.0f]]):format(self.progress), + ([["message": "%s"]]):format(self.message), + ([["context": %s]]):format(context), + } + return M.utils.string.escape(([[{%s}]]):format(table.concat(status, ", "))) + end, +}) + +--- Update job status JSON schema from its OpenAPI (Swagger) definition. +-- +--- @param swagger table Swagger definition +--- @return boolean # `true` if swagger was parsed, `false` otherwise +function M.job.status:update_json_schema(swagger) + if type(swagger) ~= "table" then + return false + end + local spec = (((swagger or {}).definitions or {}).JobStatus or {}).properties or {} + if M.utils.table.is_empty(spec) then + return false + end + for k, v in pairs(spec) do + if self.json_schema[k] then + local max = v.maxLength or v.maximum + self.json_schema[k].max = max and tonumber(max) or nil + end + end + return true +end + +--- Normalize job status information. +-- +--- @param status job.status Job status instance to normalize +--- @return job.status # Normalized `job.status` instance +function M.job.status.normalize(status) + status.state = status.state or "0xDEAD57A7E" + status.clientId = M.device.id + status.progress = status.progress or 0 + status.message = status.message or "" + status.context = status.context or {} + return status +end + +--- Set job status information. +-- +--- @param status job.status Data to set +--- @return job.status # `self` for chaining +function M.job.status:set(status) + if not status.state then + error("Internal Error: job.status:set() must be called with the .state field set.") + end + if self.definitionHash and status.definitionHash and self.definitionHash ~= status.definitionHash then + suricatta.notify.info("Job definition changed, getting new version from server...") + self.meta.definition:update() + end + return M.utils.table.merge(self.normalize(self), status) +end + +--- @class retry_settings +--- @field retries number Number of (re-)tries +--- @field retry_sleep number Number of seconds between retries + +--- Send job status information and update job with response. +-- +-- *Note*: Sends status information to wfx and SWUpdate's progress interface. +-- +--- @param chan? suricatta.open_channel The channel to send the status update over +--- @param retry? retry_settings Retry configuration +--- @return boolean # `true` if job status information sent, `false` on error +function M.job.status:send(chan, retry) + chan = chan or M.channel.main + local retries = (retry or {}).retries or chan.options.retries + local retry_sleep = (retry or {}).retry_sleep or chan.options.retry_sleep + local msg = tostring(self) + return M.utils.do_retry(retries, retry_sleep, function() + local res, _, data = chan.put { + url = ("%s/jobs/%s/status"):format(chan.options.url, self.meta.id), + content_type = "application/json", + method = suricatta.channel.method.PUT, + format = suricatta.channel.content.JSON, + headers_to_send = { + -- Filter out unneeded reply's context to save some bandwidth. + ["X-Response-Filter"] = "del(.context)", + }, + request_body = msg, + } + if not res then + suricatta.notify.error( + "Cannot transition to state '%s': HTTP Error %d.", + self.state, + data.http_response_code + ) + return false + end + suricatta.notify.debug(("[%3s%%%%] %s"):format(self.progress, self.message)) + suricatta.notify.progress(("[%3s%%] %s"):format(self.progress, self.message)) + if not M.utils.table.is_empty((data or {}).json_reply or {}) then + self:set(data.json_reply) + end + return true + end) +end + +-- ┌─────────────────────────────────────────────────────────────────────────── +-- │ Job Workflow +-- └─────────────────────────────────────────────────────────────────────────── + +--- Function prototype transition execution functions adhere to. +--- @alias dispatch_fn fun(job.workflow.transition, ...):transition.result, ... + +--- Job Workflow. +-- +-- The `states` Table is indexable by a state's name. +-- The `transitions` Table, indexed by a state name, holds all CLIENT-eligible transitions from that state. +-- +--- @class job.workflow +--- @field name string Unique Workflow name +--- @field states job.workflow.state[] | {[string]: job.workflow.state} States of the Workflow +--- @field transitions job.workflow.transition[] | {[string]: job.workflow.transition[]} Transitions of the Workflow +--- @field groups job.workflow.group[] | {[string]: job.workflow.group} State Groups of the Workflow +--- @field meta job This job.workflows' job +M.job.workflow = { + --- Workflow `Group` type definition. + -- + --- @class job.workflow.group + --- @field name string Group name + --- @field states string[] Name of States in this Group + + --- Workflow `state` type definition. + -- + --- @class job.workflow.state + --- @field name string State name + --- @field description string Description of the state + --- @field group string | nil Group the state belongs to + --- @field type state.types State's type ∈ { REGULAR, CLOSED, FAILED } + + --- Workflow `Transition` type definition. + -- + --- @class job.workflow.transition + --- @field from job.workflow.state Source state + --- @field to job.workflow.state Target state + --- @field description string Description of the transition + --- @field eligible transition.eligibles Actor that may execute the transition + --- @field execute dispatch_fn Transition execution function + + --- Transition execution function Dispatch. + -- + -- Table holding transition execution functions and their access functions. + -- + --- @class job.workflow.dispatch + dispatch = {}, +} + +--- Get a transition execution function. +-- +--- @param from string Source state name +--- @param to string Target state name +--- @return dispatch_fn? # Transition execution function +function M.job.workflow.dispatch:get(from, to) + return (self.__funcs or {})[from .. "→" .. to] +end + +--- Set a transition execution function. +-- +--- @param from string Source state name +--- @param to string Target state name +--- @param fun dispatch_fn Transition execution function +function M.job.workflow.dispatch:set(from, to, fun) + self.__funcs = self.__funcs or {} + self.__funcs[from .. "→" .. to] = fun +end + +--- Validate job Workflow. +-- +--- @return boolean # `true` if the job's workflow is valid, `false` otherwise +function M.job.workflow:validate() + for _, trans in ipairs(self.transitions) do + -- Note: :validate() is called prior to :prepare(), so not augmented yet. + --- @cast trans { [string]: string } + if trans.eligible == M.transition.eligibles.CLIENT and not self.dispatch:get(trans.from, trans.to) then + suricatta.notify.error( + "Validation Error: Transition execution function %s → %s not defined.", + trans.from, + trans.to + ) + return false + end + end + return true +end + +--- Augment the Workflow with meta functionality. +-- +-- *Note*: wfx makes sure a workflow is sound, i.e., if a state +-- is referenced in a transition, it's actually a valid state. +-- +--- @param self job.workflow This `job.workflow` instance +--- @return boolean # `true` if preparation succeeded, `false` otherwise +function M.job.workflow:prepare() + local group_type = {} + for _, n in ipairs(M.dau.wfx_group.failed) do + group_type[n] = M.state.types.FAILED + end + for _, n in ipairs(M.dau.wfx_group.closed) do + group_type[n] = M.state.types.CLOSED + end + + local state_group = {} + for _, g in ipairs(self.groups or {}) do + for _, s in ipairs(g.states) do + state_group[s] = g.name + end + self.groups[g.name] = g + end + for _, s in ipairs(self.states or {}) do + s.group = state_group[s.name] + s.type = group_type[s.group] or M.state.types.REGULAR + self.states[s.name] = s + end + + for i, t in ipairs(self.transitions or {}) do + local trans = { + from = self.states[t.from], + to = self.states[t.to], + eligible = t.eligible, + execute = self.dispatch:get(self.states[t.from].name, self.states[t.to].name), + } + self.transitions[i] = trans + if trans.eligible == M.transition.eligibles.CLIENT then + self.transitions[trans.from.name] = self.transitions[trans.from.name] or {} + self.transitions[trans.from.name][#self.transitions[trans.from.name] + 1] = trans + end + end + return true +end + +--- Execute a transition. +-- +--- @param self job.workflow This `job.workflow` instance +--- @param from string Source state name +--- @param to string Target state name +--- @param ... any Additional data passed to the transition execution function, if any +--- @return transition.result # Result of transition execution function or `nil` if none found +--- @return ...? # Additional return values of the transition execution function +function M.job.workflow:call(from, to, ...) + --- @type job.workflow.transition + local trans = M.utils.table.ifilter(self.transitions[from] or {}, function(t) + --- @cast t job.workflow.transition + return t.to.name ~= to + end)[1] + if not trans then + error(("Runtime Error: More than one transition %s → %s or not existent!"):format(from, to)) + end + suricatta.notify.info( + "Invoking transition %s:%s → %s:%s", + trans.from.type, + trans.from.name, + trans.to.type, + trans.to.name + ) + return trans:execute(...) +end + +--- Helper function actually executing transition execution function(s). +-- +--- @param self job.workflow This `job.workflow` instance +--- @param silent boolean Whether to be verbose or not +--- @param func fun(job.workflow.transition):boolean Filter function; evaluates to `true` to filter out item +--- @param ... any Additional data passed to the transition execution function, if any +--- @return transition.result # Result of transition execution function +--- @return ...? # Additional return values of the transition execution function +local function do_advance(self, silent, func, ...) + local message = { + [M.transition.result.COMPLETED] = nil, + [M.transition.result.FAIL_NEXT] = "Transition %s → %s execution failed, trying next.", + [M.transition.result.FAIL_YIELD] = "Transition %s → %s execution failed, yielding to wfx.", + } + for trans in M.dau.sequencer(M.utils.table.ifilter(self.transitions[self.meta.status.state] or {}, func)) do + if not silent then + local msg = (select(1, ...)) + --- @cast trans job.workflow.transition + suricatta.notify.info( + "Executing transition %s:%s → %s:%s%s", + trans.from.type, + trans.from.name, + trans.to.type, + trans.to.name, + type(msg) == "string" and (" (%s)"):format(msg) or "" + ) + end + local result = table.pack(trans:execute(...)) + if message[result] then + suricatta.notify.debug(string.format(message[result[1]], trans.from.name, trans.to.name)) + end + if M.utils.table.contains({ M.transition.result.COMPLETED, M.transition.result.FAIL_YIELD }, result[1]) then + return table.unpack(result) + end + end + suricatta.notify.debug("No (further) transition to execute found.") + return M.transition.result.FAIL_YIELD +end + +--- Advance Workflow executing client transitions. +-- +--- @param self job.workflow This `job.workflow` instance +--- @param ... any Additional data passed to the transition execution function, if any +--- @return transition.result # Result of transition execution function +--- @return ...? # Additional return values of the transition execution function +function M.job.workflow:advance(...) + return do_advance(self, false, function(t) + --- @cast t job.workflow.transition + return not M.utils.table.contains({ M.state.types.CLOSED, M.state.types.REGULAR }, t.to.type) + end, ...) +end + +--- Test if Workflow can be advanced by the client from its current state. +-- +--- @param self job.workflow This `job.workflow` instance +--- @return boolean # `true` if Workflow can be advanced from its current state, `false` otherwise +function M.job.workflow:can_advance() + return not M.utils.table.is_empty( + M.utils.table.ifilter(self.transitions[self.meta.status.state] or {}, function(t) + --- @cast t job.workflow.transition + return not M.utils.table.contains({ M.state.types.CLOSED, M.state.types.REGULAR }, t.to.type) + end) + ) +end + +--- Terminate Workflow processing with Failure. +-- +--- @param self job.workflow This `job.workflow` instance +--- @param ... any Additional data passed to the transition execution function, if any +--- @return transition.result # Result of transition execution function +--- @return ...? # Additional return values of the transition execution function +function M.job.workflow:fail(...) + return do_advance(self, false, function(t) + --- @cast t job.workflow.transition + return t.to.type ~= M.state.types.FAILED + end, ...) +end + +--- Report in-state progress to wfx. +-- +--- @param self job.workflow This `job.workflow` instance +--- @param ... any Additional data passed to the transition execution function, if any +--- @return transition.result # Result of transition execution function +--- @return ...? # Additional return values of the transition execution function +function M.job.workflow:report_progress(...) + return do_advance(self, true, function(t) + --- @cast t job.workflow.transition + return t.to.type ~= M.state.types.REGULAR or t.from.name ~= t.to.name + end, ...) +end + +--- Test if the current state is a terminal state. +-- +--- @param self job.workflow This `job.workflow` instance +--- @param status job.status Job status to test for terminal state +--- @return boolean? # `true` if current state is terminal, `false` otherwise +function M.job.workflow:terminated(status) + M.job.status.normalize(status) + return M.utils.table.contains( + { M.state.types.FAILED, M.state.types.CLOSED }, + (self.states[status.state] or {}).type + ) +end + +-- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +-- ▏ +-- ▏ DeviceArtifactUpdate +-- ▏ +-- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +--- DeviceArtifactUpdate ― Semantic Workflow Group Mapping. +-- +--- @class dau.wfx_group +--- @field query string[] Group name(s) used when querying wfx for (new) jobs +--- @field failed string[] Group name(s) of a failed update's terminal states +--- @field closed string[] Group name(s) of a successful update's terminal states +M.dau.wfx_group = { + query = { "OPEN" }, + failed = { "FAILED" }, + closed = { "CLOSED" }, +} + +--- @enum state.dau +--- State names in the DeviceArtifactUpdate Workflow Family +M.state.dau = { + DOWNLOAD = "DOWNLOAD", + DOWNLOADING = "DOWNLOADING", + DOWNLOADED = "DOWNLOADED", + INSTALL = "INSTALL", + INSTALLING = "INSTALLING", + INSTALLED = "INSTALLED", + ACTIVATE = "ACTIVATE", + ACTIVATING = "ACTIVATING", + ACTIVATED = "ACTIVATED", + TERMINATED = "TERMINATED", +} + +--- Transition Sequencer Iterator. +-- +-- The transition sequencer iterator returns the transition to be executed next +-- out of a set of eligible transitions, in a Workflow (Family)-specific order. +-- +-- This simple algorithm sorts the given transitions into 3 buckets: REGULAR, +-- CLOSED, and FAILED. The REGULAR transition bucket is sorted so that self- +-- looping transitions are prioritized, i.e., "non-greedy". Ordering the CLOSED +-- and FAILED transition bucket is redundant as each results in a terminal state. +-- This sorted list is returned as an iterator. +-- +--- @param transitions job.workflow.transition[] Eligible transitions +--- @return function # The iterator function +--- @return table # The modified `transitions` table +function M.dau.sequencer(transitions) + local trans = { + [M.state.types.REGULAR] = 1, + [M.state.types.CLOSED] = 2, + [M.state.types.FAILED] = 3, + [1] = {}, + [2] = {}, + [3] = {}, + } + for _, t in ipairs(transitions or {}) do + local bucket = trans[t.to.type] + trans[bucket][#trans[bucket] + 1] = t + end + table.sort(trans[1], function(a, _) + --- @cast a job.workflow.transition + if a.from.name == a.to.name then + -- Sort self-looping transitions first. + return true + end + return false + end) + local i = { bucket = 1, index = 0 } + local function get(tab) + i.index = i.index + 1 + if tab[i.bucket][i.index] ~= nil then + return tab[i.bucket][i.index] + end + i.bucket, i.index = i.bucket + 1, 0 + if not tab[i.bucket] then + return + end + return get(tab) + end + return get, trans +end + +-- ┌─────────────────────────────────────────────────────────────────────────── +-- │ DeviceArtifactUpdate :: Definition +-- └─────────────────────────────────────────────────────────────────────────── + +--- Default update artifact type (i.e., activation mode). +M.dau.DEFAULT_UPDATE_TYPE = "firmware" + +--- DeviceArtifactUpdate ― Job Definition. +-- +--- @class job.definition +--- @field version string Version of the Update +--- @field type string[] | {[string]: number} Type of the Update +--- @field artifacts job.definition.artifact[] List of Artifacts to install +--- @field meta job This job definitions' `job` +M.job.definition = {} + +--- DeviceArtifactUpdate ― Artifacts Definition. +-- +--- @class job.definition.artifact +--- @field name string (File) name of the artifact +--- @field version string Version of the Artifact +--- @field uri string URI where the artifact can be downloaded + +--- DeviceArtifactUpdate ― Definition JSON Schema +--- @class job.definition.json_schema +M.job.definition.json_schema = { + version = { type = "string" }, + type = { type = {}, value = { [1] = { type = "string" } } }, + artifacts = { + type = {}, + value = { + [1] = { + type = {}, + value = { + name = { type = "string", optional = true }, + version = { type = "string", optional = true }, + uri = { type = "string" }, + }, + }, + }, + }, +} + +--- Update job Definition from server. +-- +-- *Note*: This is a synchronous operation since the next transition execution +-- function may rely on a changed job definition's data. +-- +--- @param chan? suricatta.open_channel The channel to get the definition update over +function M.job.definition:update(chan) + chan = chan or M.channel.main + M.utils.do_retry(math.huge, chan.options.retry_sleep, function() + local res, _, data = chan.get { + url = ("%s/job/%s/definition"):format(chan.options.url, self.meta.id), + } + if not res then + suricatta.notify.warn("Got HTTP error code %d from server.", data.http_response_code) + return false + end + if M.utils.table.is_empty((data or {}).json_reply or {}) then + suricatta.notify.warn("Got bad JSON response from server: %q", tostring(data)) + return false + end + if not self:validate(data.json_reply) then + return false + end + local t = getmetatable(self) + t.__index = data.json_reply + self = setmetatable(self, t) + return true + end) +end + +--- Augment job Definition with meta functionality. +-- +--- @return boolean # `true` if preparation succeeded, `false` otherwise +function M.job.definition:prepare() + if #table.concat(self.type, ":") > 2048 then + suricatta.notify.error("Validation Error: definition's .type is > %d chars.", 2048 - (#self.type - 1)) + return false + end + for i, v in ipairs(self.type) do + self.type[v] = i + end + return true +end + +--- Validate job Definition. +-- +--- @param definition job.definition? Job definition other than `self`'s +--- @return boolean # `true` if the job's definition is valid, `false` otherwise +function M.job.definition:validate(definition) + return M.utils.validate_json_schema(definition or self, self.json_schema) and self:prepare() +end + +--- Test whether update artifact is of (default) type "firmware". +-- +-- If one of `.type`'s tags matches `DEFAULT_UPDATE_TYPE` == "firmware", only then the +-- update is to be firmware-activated, i.e., via rebooting into the new firmware. +-- +-- Other tags are passed verbatim to progress client(s), concatenated with ":", +-- for them to take according activation actions. +-- +--- @return boolean # `true` if activation method is "firmware" (default), `false` if not +function M.job.definition:is_firmware() + return self.type[M.dau.DEFAULT_UPDATE_TYPE] ~= nil +end + +-- ┌─────────────────────────────────────────────────────────────────────────── +-- │ DeviceArtifactUpdate :: Transitions +-- └─────────────────────────────────────────────────────────────────────────── + +--- Helper function to truncate installation logs to maximal length. +-- +--- @param log table Table with log lines +--- @param maxlength number Maximal String length +--- @param delimiter string JSON list delimiter +--- @return string[] # Table with log lines, `delimiter`-concatenated less than `maxlength` characters +local function prepare_logs(log, maxlength, delimiter) + local details = {} + -- Some headroom for JSON enclosure in status message. + local length = 50 + for index, line in M.utils.ripairs(log or {}) do + local message = ([[%d: %s]]):format(index, M.utils.string.escape(line)) + -- Add JSON list delimiter length plus \n to string length + length = length + #message + #delimiter + 1 + if length > maxlength then + break + end + table.insert(details, 1, message) + end + return details +end + +--- @class job_status +--- @field [1] job.workflow.transition This `job.workflow.transition` instance +--- @field [2] job This `job` instance +--- @field state? string Target state, defaults to this transition's `to.name` +--- @field message? string State update reason message +--- @field progress? number Current job progress percentage [0,100] +--- @field context? string[] Additional context information. +--- @field channel? suricatta.open_channel The channel to send the status update over +--- @field retry? retry_settings Retry settings, else channel defaults are used + +--- Helper function to send & sync Job Status with wfx. +-- +-- *Note*: `job.status.state` is set to this transition's `to` state. +-- +--- @param status job_status +--- @return boolean +local function sync_job_status(status) + if #status > 2 then + error("Internal Error: sync_job_status() called with > 2 positional parameters.") + end + local transition = status[1] + local job = status[2] + return job.status + :set({ + state = status.state or transition.to.name, + message = status.message or "", + progress = status.progress or 100, + context = status.context or {}, + }) + :send(status.channel, status.retry) +end + +--- Helper function to send update activation progress message. +-- +-- Send update activation message via SWUpdate's progress interface +-- to subscribed progress clients. +-- +--- @param job job +local function send_progress_activation_msg(job) + suricatta.notify.progress(M.utils.string + .escape([[ + { + "state": "ACTIVATING", + "progress": 100, + "message": "%s" + } + ]]) + :format(table.concat(job.definition.type, ":"))) +end + +--- Helper function to set persistent bootloader state. +-- +-- Storing the persistent state `pstate` is retried up to 3 times +-- with 3 seconds pause in between each try if it has failed. +-- +--- @param pstate number Any of `suricatta.pstate`'s enum values +--- @return boolean # `true` if persistent state is set, `false` otherwise +local function save_pstate(pstate) + suricatta.notify.debug("Setting persistent state to %s.", suricatta.pstate[pstate]) + return M.utils.do_retry(3, 3, function() + if suricatta.pstate.save(pstate) then + suricatta.notify.debug("Persistent state set to %s.", suricatta.pstate[pstate]) + return true + end + suricatta.notify.error("Error setting persistent state information, retrying in a few seconds.") + return false + end) +end + +--- Helper function to check if `pstate` allows starting a new job. +-- +--- @param self job.workflow.transition +--- @param job job +--- @return boolean +local function is_pstate_ok(self, job) + if M.device.pstate == suricatta.pstate.INSTALLED then + suricatta.notify.warn("Started new job while last one's activation is pending, notifying progress.") + send_progress_activation_msg(job) + return false + end + if M.utils.table.contains({ suricatta.pstate.FAILED, suricatta.pstate.TESTING }, M.device.pstate) then + local msg = ("Persistent state is %s at job start, spurious update test?"):format( + suricatta.pstate[M.device.pstate] or "N/A" + ) + suricatta.notify.warn(msg) + sync_job_status { self, job, message = msg, progress = 0 } + end + return true +end + +M.job.workflow.dispatch:set( + M.state.dau.DOWNLOAD, + M.state.dau.DOWNLOADING, + --- @param self job.workflow.transition + --- @param job job + --- @return transition.result + function(self, job) + local ok = is_pstate_ok(self, job) + if not ok then + return M.transition.result.FAIL_YIELD + end + + if not sync_job_status { self, job, message = ("Start %s."):format(self.from.name), progress = 0 } then + return M.transition.result.FAIL_YIELD + end + + M.channel.progress = M.channel(M.utils.table.merge({ noprogress = true }, M.channel.main.options)) + if not M.channel.progress then + suricatta.notify.warn("Cannot initialize progress reporting channel, won't send progress.") + end + + suricatta.notify.debug( + "%s Version '%s' (Type: %s).", + self.to.name, + job.definition.version, + table.concat(job.definition.type, ":") + ) + + for count, artifact in pairs(job.definition.artifacts) do + local source_uri = M.utils.string.escape(artifact.uri, { ['"'] = '"', ["\\"] = "" }) + local target_uri = ("%s%s_%d.swu"):format(suricatta.get_tmpdir(), job.id, count) + suricatta.notify.debug( + "[ 0%%] [%s] Artifact %d/%d: '%s' from %q.", + self.to.name, + count, + #job.definition.artifacts, + artifact.name, + source_uri + ) + local res, _, updatelog = suricatta.download({ channel = M.channel.main, url = source_uri }, target_uri) + if not res then + local msg = ("Error downloading artifact %d/%d."):format(count, #job.definition.artifacts) + suricatta.notify.error(msg) + return job.workflow:fail( + job, + msg, + prepare_logs( + updatelog, + job.status.json_schema.context.max, + job.status.json_schema.context.delimiter + ) + ) + end + sync_job_status { + self, + job, + message = ("Artifact %d/%d download finished."):format(count, #job.definition.artifacts), + progress = math.floor(100 / #job.definition.artifacts * count), + } + end + + if M.channel.progress then + M.channel.progress.close() + M.channel.progress = nil + end + + sync_job_status { self, job, message = "Finished." } + + -- Tail-call transition DOWNLOADING → DOWNLOADED without detour via wfx query. + return job.workflow:call(M.state.dau.DOWNLOADING, M.state.dau.DOWNLOADED, job) + end +) + +M.job.workflow.dispatch:set( + M.state.dau.DOWNLOAD, + M.state.dau.TERMINATED, + --- @param self job.workflow.transition + --- @param job job + --- @return transition.result + function(self, job) + if not sync_job_status { self, job } then + return M.transition.result.FAIL_YIELD + end + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.DOWNLOADING, + M.state.dau.DOWNLOADING, + --- @param self job.workflow.transition + --- @param job job + --- @param message? suricatta.ipc.progress_msg + --- @return transition.result + function(self, job, message) + if not message then + -- Called as "regular" transition in response to wfx query. + -- This happens if: + -- * DOWNLOADING → TERMINATED has failed or + -- * DOWNLOADING → DOWNLOADED has failed. + -- In both cases, terminate the update. + return job.workflow:fail(job, "wfx sync has failed, terminating update.") + end + -- Called as progress reporting callback transition. + if not M.channel.progress or not message.source == suricatta.ipc.sourcetype.SOURCE_SURICATTA then + return M.transition.result.FAIL_YIELD + end + if message.status == suricatta.ipc.RECOVERY_STATUS.DOWNLOAD then + if M.server.progress_rate_limited(message.dwl_percent, job.status.progress) then + return M.transition.result.COMPLETED + end + -- Informational progress reports to wfx are not retried. + sync_job_status { + self, + job, + channel = M.channel.progress, + message = "Downloading artifact", + progress = message.dwl_percent, + retry = { retries = 1 }, + } + end + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.DOWNLOADING, + M.state.dau.DOWNLOADED, + --- @param self job.workflow.transition + --- @param job job + --- @return transition.result + function(self, job) + if not sync_job_status { self, job, message = "Finished." } then + return M.transition.result.FAIL_YIELD + end + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.DOWNLOADING, + M.state.dau.TERMINATED, + --- @param self job.workflow.transition + --- @param job job + --- @param message? string + --- @param context? string[] + --- @return transition.result + function(self, job, message, context) + if not sync_job_status { self, job, message = message, context = context } then + return M.transition.result.FAIL_YIELD + end + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.INSTALL, + M.state.dau.INSTALLING, + --- @param self job.workflow.transition + --- @param job job + --- @return transition.result + function(self, job) + local ok = is_pstate_ok(self, job) + if not ok then + return M.transition.result.FAIL_YIELD + end + + if job.workflow.transitions[M.state.dau.DOWNLOAD] then + local function file_exists(name) + local f = io.open(name, "r") + return f ~= nil and io.close(f) + end + -- Check that all to be installed artifact files are downloaded. + for count, _ in pairs(job.definition.artifacts) do + local file = ("%s%s_%d.swu"):format(suricatta.get_tmpdir(), job.id, count) + if not file_exists(file) then + local msg = ("Artifact %d/%d not downloaded to '%s'.."):format( + count, + #job.definition.artifacts, + file + ) + suricatta.notify.error(msg) + return job.workflow:fail(job, msg) + end + end + end + + if not sync_job_status { self, job, message = ("Start %s."):format(self.from.name), progress = 0 } then + return M.transition.result.FAIL_YIELD + end + + M.channel.progress = M.channel(M.utils.table.merge({ noprogress = true }, M.channel.main.options)) + if not M.channel.progress then + suricatta.notify.warn("Cannot initialize progress reporting channel, won't send progress.") + end + + suricatta.notify.debug( + "%s Version '%s' (Type: %s).", + self.to.name, + job.definition.version, + table.concat(job.definition.type, ":") + ) + + for count, artifact in pairs(job.definition.artifacts) do + local source_uri = M.utils.string.escape(artifact.uri, { ['"'] = '"', ["\\"] = "" }) + if job.workflow.transitions[M.state.dau.DOWNLOAD] then + source_uri = ("file://%s%s_%d.swu"):format(suricatta.get_tmpdir(), job.id, count) + end + suricatta.notify.debug( + "[ 0%%] Artifact %d/%d: '%s' from %q", + count, + #job.definition.artifacts, + artifact.name, + source_uri + ) + local res, _, updatelog = suricatta.install { channel = M.channel.main, url = source_uri } + -- Unconditionally remove the artifact file ignoring errors, e.g., if run in streaming mode. + os.remove(("%s%s_%d.swu"):format(suricatta.get_tmpdir(), job.id, count)) + if not res then + local msg = ("Error installing artifact %d/%d."):format(count, #job.definition.artifacts) + suricatta.notify.error(msg) + return job.workflow:fail( + job, + msg, + prepare_logs( + updatelog, + job.status.json_schema.context.max, + job.status.json_schema.context.delimiter + ) + ) + end + sync_job_status { + self, + job, + message = ("Artifact %d/%d installation finished."):format(count, #job.definition.artifacts), + progress = math.floor(100 / #job.definition.artifacts * count), + } + end + + if M.channel.progress then + M.channel.progress.close() + M.channel.progress = nil + end + + sync_job_status { self, job, message = "Finished." } + + -- Tail-call transition INSTALLING → INSTALLED without detour via wfx query. + return job.workflow:call(M.state.dau.INSTALLING, M.state.dau.INSTALLED, job) + end +) + +M.job.workflow.dispatch:set( + M.state.dau.INSTALL, + M.state.dau.TERMINATED, + --- @param self job.workflow.transition + --- @param job job + --- @return transition.result + function(self, job) + if not sync_job_status { self, job } then + return M.transition.result.FAIL_YIELD + end + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.INSTALLING, + M.state.dau.INSTALLING, + --- @param self job.workflow.transition + --- @param job job + --- @param message? suricatta.ipc.progress_msg + --- @return transition.result + function(self, job, message) + if not message then + -- Called as "regular" transition in response to wfx query. + -- This happens if: + -- * INSTALLING → TERMINATED has failed or + -- * INSTALLING → INSTALLED has failed. + -- In both cases, terminate the update. + return job.workflow:fail(job, "wfx sync has failed, terminating update.") + end + + if not M.channel.progress or not message.source == suricatta.ipc.sourcetype.SOURCE_SURICATTA then + return M.transition.result.FAIL_YIELD + end + + if message.status == suricatta.ipc.RECOVERY_STATUS.DOWNLOAD then + if M.server.progress_rate_limited(message.dwl_percent, job.status.progress) then + return M.transition.result.COMPLETED + end + local msg = "Downloading artifact" + if job.workflow.transitions[M.state.dau.DOWNLOAD] then + msg = ("Copying artifact to TMPDIR=%s"):format(suricatta.get_tmpdir()) + end + -- Informational progress reports to wfx are not retried. + sync_job_status { + self, + job, + channel = M.channel.progress, + message = msg, + progress = message.dwl_percent, + retry = { retries = 1 }, + } + return M.transition.result.COMPLETED + end + + if message.status == suricatta.ipc.RECOVERY_STATUS.PROGRESS then + -- Don't report installing message if the 'dummy' handler is used or no current image is set. + if #message.hnd_name == 0 or #message.cur_image == 0 or message.cur_step == 0 then + return M.transition.result.COMPLETED + end + if M.server.progress_rate_limited(message.cur_percent, job.status.progress) then + return M.transition.result.COMPLETED + end + -- Informational progress reports to wfx are not retried. + sync_job_status { + self, + job, + channel = M.channel.progress, + message = ("Installing artifact %d/%d: '%s' with '%s' ..."):format( + message.cur_step, + message.nsteps, + message.cur_image, + message.hnd_name + ), + progress = message.cur_percent, + retry = { retries = 1 }, + } + return M.transition.result.COMPLETED + end + + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.INSTALLING, + M.state.dau.INSTALLED, + --- @param self job.workflow.transition + --- @param job job + --- @return transition.result + function(self, job) + if not sync_job_status { self, job, message = "Finished." } then + return M.transition.result.FAIL_YIELD + end + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.INSTALLING, + M.state.dau.TERMINATED, + --- @param self job.workflow.transition + --- @param job job + --- @param message? string + --- @param context? string[] + --- @return transition.result + function(self, job, message, context) + if not sync_job_status { self, job, message = message, context = context } then + return M.transition.result.FAIL_YIELD + end + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.ACTIVATE, + M.state.dau.ACTIVATING, + --- @param self job.workflow.transition + --- @param job job + --- @return transition.result + function(self, job) + if not job.definition:is_firmware() then + if M.device.pstate ~= suricatta.pstate.OK then + suricatta.notify.warn("Persistent state is not OK (is %s).", suricatta.pstate[M.device.pstate]) + if not save_pstate(suricatta.pstate.OK) then + return M.transition.result.FAIL_YIELD + end + M.device.pstate = suricatta.pstate.OK + end + if not sync_job_status { self, job, message = "Notifying Progress Client(s) to activate artifact." } then + return M.transition.result.FAIL_YIELD + end + send_progress_activation_msg(job) + -- Tail-call transition ACTIVATING → ACTIVATED without detour via wfx query. + return job.workflow:call(M.state.dau.ACTIVATING, M.state.dau.ACTIVATED, job) + end + + suricatta.notify.debug("Job activation mode is 'firmware'.") + if suricatta.pstate.get() == suricatta.pstate.INSTALLED then + suricatta.notify.warn("Persistent state is already set to INSTALLED.") + else + if not save_pstate(suricatta.pstate.IN_PROGRESS) or not save_pstate(suricatta.pstate.INSTALLED) then + return M.transition.result.FAIL_YIELD + end + end + M.device.pstate = suricatta.pstate.INSTALLED + + if not sync_job_status { self, job, message = "Set persistent state marker to INSTALLED." } then + return M.transition.result.FAIL_YIELD + end + -- Tail-call transition ACTIVATING → ACTIVATING without detour via wfx query. + return job.workflow:call(M.state.dau.ACTIVATING, M.state.dau.ACTIVATING, job, M.device.pstate) + end +) + +M.job.workflow.dispatch:set( + M.state.dau.ACTIVATE, + M.state.dau.TERMINATED, + --- @param self job.workflow.transition + --- @param job job + --- @return transition.result + function(self, job) + if not sync_job_status { self, job } then + return M.transition.result.FAIL_YIELD + end + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.ACTIVATING, + M.state.dau.ACTIVATING, + --- @param self job.workflow.transition + --- @param job job + --- @param pstate? number + --- @return transition.result + function(self, job, pstate) + if not job.definition:is_firmware() then + -- Called as "regular" transition in response to wfx query. + -- This happens if the tail-call ACTIVATING → ACTIVATED has + -- failed. As progress clients have been notified, mark this + -- update as ACTIVATED. + return job.workflow:call(M.state.dau.ACTIVATING, M.state.dau.ACTIVATED, job) + end + + -- Refresh `pstate` if dispatched in response to wfx query. + M.device.pstate = pstate or suricatta.pstate.get() + + if M.device.pstate == suricatta.pstate.OK then + local msg = "Persistent state is already set to OK. Finished update." + suricatta.notify.info(msg) + return job.workflow:call(M.state.dau.ACTIVATING, M.state.dau.ACTIVATED, job, msg) + end + + if M.device.pstate == suricatta.pstate.INSTALLED then + sync_job_status { self, job, message = "Update installed. Notifying Progress Client(s) to reboot." } + send_progress_activation_msg(job) + return M.transition.result.COMPLETED + end + + if M.device.pstate == suricatta.pstate.TESTING then + local msg = ("Update activation SUCCESSFUL, finalizing job %s."):format(job.id) + suricatta.notify.info(msg) + if not save_pstate(suricatta.pstate.OK) then + return M.transition.result.FAIL_YIELD + end + M.device.pstate = suricatta.pstate.OK + return job.workflow:call(M.state.dau.ACTIVATING, M.state.dau.ACTIVATED, job, msg) + end + + if M.device.pstate == suricatta.pstate.FAILED then + local msg = ("Update activation FAILED, failing job %s ..."):format(job.id) + suricatta.notify.error(msg) + if not save_pstate(suricatta.pstate.OK) then + return M.transition.result.FAIL_YIELD + end + M.device.pstate = suricatta.pstate.OK + return job.workflow:fail(job, msg) + end + + local msg = ("%s → %s called with unhandled persistent state %s."):format( + self.from.name, + self.to.name, + suricatta.pstate[M.device.pstate] or "N/A" + ) + suricatta.notify.error(msg) + sync_job_status { self, job, msg } + return M.transition.result.FAIL_YIELD + end +) + +M.job.workflow.dispatch:set( + M.state.dau.ACTIVATING, + M.state.dau.ACTIVATED, + --- @param self job.workflow.transition + --- @param job job + --- @param message? string + --- @return transition.result + function(self, job, message) + if not sync_job_status { self, job, message = message or "Finished Update." } then + return M.transition.result.FAIL_YIELD + end + return M.transition.result.COMPLETED + end +) + +M.job.workflow.dispatch:set( + M.state.dau.ACTIVATING, + M.state.dau.TERMINATED, + --- @param self job.workflow.transition + --- @param job job + --- @param message? string + --- @return transition.result + function(self, job, message) + if not sync_job_status { self, job, message = message } then + return M.transition.result.FAIL_YIELD + end + return M.transition.result.COMPLETED + end +) + +-- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +-- ▏ +-- ▏ Suricatta wfx-Binding Types & Definitions +-- ▏ +-- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +--- Device state and information. +-- +--- @class device +--- @field pstate number Persistent state ID number ∈ suricatta.pstate +--- @field id string Device ID +--- @field version string Device client version information, sent to server in HTTP Header +--- @field reset function(device) Reset `device` Table +M.device = { + version = ("SWUpdate %s.%s"):format(table.unpack(suricatta.getversion() or { 2023, 5 })), + reset = function(self) + self.id = nil + self.pstate = nil + end, +} + +--- Device ↔ Server communication channel state and information. +-- +--- @type {[string]: suricatta.open_channel} +M.channel = setmetatable({}, { + __call = function(_, opts) + local res, chan = suricatta.channel.open(opts) + if not res then + return nil + end + return chan + end, +}) + +--- Server information. +-- +--- @class server +--- @field polldelay number Delay between two wfx poll operations in seconds +--- @field reset function(server) Reset `server` Table +M.server = { + reset = function(self) + self.polldelay = nil + end, +} + +--- Rate-limit the number of progress messages to wfx. +-- +--- @param percent number Progress in percent +--- @param last number Last Progress in percent +--- @return boolean # `true` if progress message shouldn't be sent +M.server.progress_rate_limited = function(percent, last) + local scale = 10 / 100 + return math.floor(scale * last) == math.floor(scale * percent) +end + +-- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +-- ▏ +-- ▏ Suricatta Interface Implementation +-- ▏ +-- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +--- Progress thread callback handler reporting progress to wfx. +-- +--- @param message suricatta.ipc.progress_msg The progress message +--- @return suricatta.status # Suricatta return code +function M.suricatta_funcs.progress_callback(message) + if M.job.workflow:report_progress(M.job, message) == M.transition.result.COMPLETED then + return suricatta.status.OK + end + return suricatta.status.EERR +end +suricatta.server.register(M.suricatta_funcs.progress_callback, suricatta.server.CALLBACK_PROGRESS) + +--- Query and handle pending actions on the wfx. +-- +-- Lua counterpart of `server_op_res_t server_has_pending_action(int *action_id)`. +-- +-- The suricatta.status return values `UPDATE_AVAILABLE` and `ID_REQUESTED` are handled +-- in `suricatta/suricatta.c`, others result in SWUpdate sleeping. +-- +--- @return suricatta.status # Suricatta return code +function M.suricatta_funcs.has_pending_action() + --- Perform a GET request on the wfx. + -- + --- @param url string URL to query + --- @return boolean # `true` if wfx responded, `false` on network error + --- @return table? # Lua Table-ified JSON response or `nil` on error + local function query_wfx(url) + suricatta.notify.debug("Suricatta querying %q", url) + local res, _, data = M.channel.main.get { + url = url, + } + if not res then + suricatta.notify.debug("Got HTTP error code %d from server.", data.http_response_code) + return false + end + if M.utils.table.is_empty((data or {}).json_reply or {}) then + suricatta.notify.warn("Got bad JSON response from server: %q", tostring(data)) + return true + end + return true, data.json_reply + end + + --- Query the wfx for a new job to process. + -- + --- @return boolean # `true` if wfx responded, `false` on network error + --- @return job? # New job to process or `nil` on error + local function get_new_job() + local response, data = query_wfx( + ("%s/jobs?clientId=%s&group=%s"):format( + M.channel.main.options.url, + M.device.id, + table.concat(M.dau.wfx_group.query, ",") + ) + ) + for _, j in ipairs((data or {}).content or {}) do + --- @cast j job + if M.job(j) then + return response, j + end + suricatta.notify.error("Job validation failed, skipping job.") + end + return response + end + + --- Get the current job, if any, to process further from the wfx. + -- + --- @return boolean # `true` if wfx responded, `false` on network error + --- @return job? # Current job as wfx sees it or `nil` on error + local function get_current_job() + if M.job:invalid() then + return true + end + return query_wfx(("%s/jobs/%s"):format(M.channel.main.options.url, M.job.id)) + end + + -- ────┤ Handle & Dispatch ├────────────────────────────────────── + local response, j = get_current_job() + if not response then + suricatta.notify.debug("Got no response from server: sleeping ...") + return suricatta.status.NO_UPDATE_AVAILABLE + end + if not j or M.job.workflow:terminated(j.status) then + M.job:invalidate() + response, j = get_new_job() + if not response or not j then + suricatta.notify.debug("Got no response or no job: sleeping ...") + return suricatta.status.NO_UPDATE_AVAILABLE + end + end + + M.job.status:set(j.status) + if not M.job.workflow:can_advance() then + suricatta.notify.debug("No (further) transitions to take: sleeping ...") + return suricatta.status.EAGAIN + end + + suricatta.notify.info("Processing job ID %s in state %s ...", M.job.id, M.job.status.state) + _ = M.job.workflow:advance(M.job) + suricatta.notify.info("Yielding to wfx.") + return suricatta.status.OK +end +suricatta.server.register(M.suricatta_funcs.has_pending_action, suricatta.server.HAS_PENDING_ACTION) + +-- Lua counterpart of `server_op_res_t server_install_update(void)`. +-- +-- This function is intended to be called by `suricatta/suricatta.c`'s event loop +-- via `has_pending_action()` returning `UPDATE_AVAILABLE`. +-- Here, this functionality is called directly from within `job.workflow:advance()` +-- so that a dummy implementation satisfies the requirement of this function +-- being registered. +suricatta.server.register(function() + return suricatta.status.OK +end, suricatta.server.INSTALL_UPDATE) + +--- Print the help text. +-- +-- Lua counterpart of `void server_print_help(void)`. +-- +--- @param defaults suricatta.channel.options|table Default options +--- @return suricatta.status # Suricatta return code +function M.suricatta_funcs.print_help(defaults) + defaults = defaults or {} + local stdout = io.output() + stdout:write("\t -u, --url * Base URL to the wfx server, e.g.,http://localhost:8080/api/wfx/v1\n") + stdout:write("\t -i, --id * The device ID\n") + stdout:write("\t -y, --proxy Use proxy. Either proxy or ${http,all}_proxy is tried\n") + stdout:write( + ("\t -p, --polldelay Delay between two poll operations (default: %s sec)\n"):format( + tostring(defaults.polldelay or "?") + ) + ) + stdout:write( + ("\t -r, --retry Retry count prior to failing (default: %s)\n"):format( + tostring(defaults.retries or "?") + ) + ) + stdout:write( + ("\t -w, --retrywait Delay between retries, e.g., download resume (default: %s sec)\n"):format( + tostring(defaults.retry_sleep or "?") + ) + ) + stdout:write("\t -c, --confirm Confirm update test result\n") + stdout:write("\t -x, --nocheckcert Ignore invalid server certificates\n") + stdout:write("\t -v Enable verbose log messages\n") + return suricatta.status.OK +end +suricatta.server.register(M.suricatta_funcs.print_help, suricatta.server.PRINT_HELP) + +--- Start the Suricatta server. +-- +-- Lua counterpart of `server_op_res_t server_start(char *fname, int argc, char *argv[])`. +-- +--- @param defaults {[string]: any} Lua suricatta module channel default options +--- @param argv string[] C's `argv` as Lua Table +--- @param fconfig {[string]: any} SWUpdate configuration file's `[suricatta]` section as Lua Table ∪ { polldelay = CHANNEL_DEFAULT_POLLING_INTERVAL} +--- @return suricatta.status # Suricatta return code +function M.suricatta_funcs.server_start(defaults, argv, fconfig) + -- Use defaults, + -- ... shadowed by configuration file values, + -- ... shadowed by command line arguments. + local configuration = defaults or {} + M.utils.table.merge(configuration, fconfig or {}) + local opts = { + { "url", M.utils.GETOPT_ARG.REQUIRED, "u" }, + { "id", M.utils.GETOPT_ARG.REQUIRED, "i" }, + { "proxy", M.utils.GETOPT_ARG.REQUIRED, "y" }, + { "polldelay", M.utils.GETOPT_ARG.REQUIRED, "p" }, + { "retry", M.utils.GETOPT_ARG.REQUIRED, "r" }, + { "retrywait", M.utils.GETOPT_ARG.REQUIRED, "w" }, + { "confirm", M.utils.GETOPT_ARG.REQUIRED, "c" }, + { "nocheckcert", M.utils.GETOPT_ARG.NO, "x" }, + { nil, M.utils.GETOPT_ARG.NO, "v" }, + } + for opt, arg in M.utils.getopt(argv or {}, opts) do + if opt == "u" then + configuration.url = tostring(arg) + elseif opt == "i" then + configuration.id = tostring(arg) + elseif opt == "y" then + configuration.proxy = tostring(arg) + elseif opt == "p" then + configuration.polldelay = tonumber(arg) or configuration.polldelay + elseif opt == "r" then + configuration.retries = tonumber(arg) or configuration.retries + elseif opt == "w" then + configuration.retry_sleep = tonumber(arg) or configuration.retry_sleep + elseif opt == "c" then + -- Legacy support for suricatta hawkBit's -c encoding + if arg:lower() == "ok" or arg == suricatta.pstate.TESTING then + configuration.pstate = suricatta.pstate.TESTING + elseif arg:lower() == "failed" or arg == suricatta.pstate.FAILED then + configuration.pstate = suricatta.pstate.FAILED + else + io.stderr:write(("ERROR: Invalid argument: -%s %s\n"):format(opt, arg)) + return suricatta.status.EINIT + end + elseif opt == "x" then + configuration.strictssl = false + elseif opt == "v" then + configuration.debug = true + elseif opt == ":" then + io.stderr:write("ERROR: Missing argument.\n") + -- Note: .polldelay is in fconfig, make defaults __index it as well. + M.print_help(setmetatable((defaults or {}), { __index = fconfig })) + return suricatta.status.EINIT + end + end + + for k, v in pairs(configuration) do + if suricatta.channel.options[k] ~= nil and type(suricatta.channel.options[k]) ~= type(v) then + suricatta.notify.error( + "Configuration type mismatch: '%s' must be %s, is %s.", + k, + type(suricatta.channel.options[k]), + type(v) + ) + return suricatta.status.EINIT + end + end + + if not configuration.url or type(configuration.url) ~= "string" then + suricatta.notify.error("Mandatory parameter missing/mis-typed: URL") + return suricatta.status.EINIT + end + if not configuration.id or type(configuration.id) ~= "string" then + suricatta.notify.error("Mandatory parameter missing/mis-typed: Device ID") + return suricatta.status.EINIT + end + M.device.id = configuration.id + + if not configuration.pstate then + M.device.pstate = suricatta.pstate.get() + if M.device.pstate == suricatta.pstate.ERROR then + suricatta.notify.error("Error reading persistent state information.") + return suricatta.status.EINIT + end + suricatta.notify.info( + "Read persistent state %s = %s.", + string.char(M.device.pstate), + suricatta.pstate[M.device.pstate] or "N/A" + ) + else + M.device.pstate = configuration.pstate + suricatta.notify.info( + "Got supplied persistent state %s = %s.", + string.char(M.device.pstate), + suricatta.pstate[M.device.pstate] or "N/A" + ) + end + + M.server.polldelay = configuration.polldelay + + M.channel.main = M.channel(M.utils.table.merge({ + -- An API Gateway may use this information to do steering. + headers_to_send = { + ["X-Client-Version"] = M.device.version, + ["X-Client-Id"] = M.device.id, + }, + }, configuration)) + if not M.channel.main then + suricatta.notify.error("Cannot initialize channel.") + return suricatta.status.EINIT + end + + local url = ("%s/swagger.json"):format(M.channel.main.options.url) + suricatta.notify.debug("Suricatta querying %q", url) + repeat + res, _, data = M.channel.main.get { url = url } + if not res then + suricatta.notify.warn( + "Got HTTP error code %d, sleeping %d seconds ...", + data.http_response_code, + M.channel.main.options.retry_sleep + ) + suricatta.sleep(M.channel.main.options.retry_sleep) + end + until res and M.job.status:update_json_schema(((data or {}).json_reply or {})) + + suricatta.notify.info("Running with device ID %q on %q", M.device.id, configuration.url) + return suricatta.status.OK +end +suricatta.server.register(M.suricatta_funcs.server_start, suricatta.server.SERVER_START) + +--- Stop the Suricatta server. +-- +-- Lua counterpart of `server_op_res_t server_stop(void)`. +-- +--- @return suricatta.status # Suricatta return code +function M.suricatta_funcs.server_stop() + for name, chan in pairs(M.channel) do + chan.close() + M.channel[name] = nil + end + M.device:reset() + M.server:reset() + return suricatta.status.OK +end +suricatta.server.register(M.suricatta_funcs.server_stop, suricatta.server.SERVER_STOP) + +--- Get wfx polling interval. +-- +-- Lua counterpart of `unsigned int server_get_polling_interval(void)`. +-- +--- @return number # Polling interval in seconds +function M.suricatta_funcs.get_polling_interval() + -- Not implemented. + -- Remotely setting the polling interval is the original concern of + -- a device management solution that should set the polling interval + -- via SWUpdate IPC (or command line or configuration file). + return M.server.polldelay +end +suricatta.server.register(M.suricatta_funcs.get_polling_interval, suricatta.server.GET_POLLING_INTERVAL) + +--- Report device configuration/data to server. +-- +-- Lua counterpart of `server_op_res_t server_send_target_data(void)`. +-- +--- @return suricatta.status # Suricatta return code +function M.suricatta_funcs.send_target_data() + -- Not implemented. + -- This is the original concern of a device management solution that + -- should report device status including data gathered from SWUpdate + -- via IPC or the progress interface. + return suricatta.status.OK +end +suricatta.server.register(M.suricatta_funcs.send_target_data, suricatta.server.SEND_TARGET_DATA) + +--- Handle IPC messages sent to Suricatta Lua module. +-- +-- Lua counterpart of `server_op_res_t server_ipc(ipc_message *msg)`. +-- +--- @param message suricatta.ipc.ipc_message The IPC message sent +--- @return string # IPC JSON reply string +--- @return suricatta.status # Suricatta return code +function M.suricatta_funcs.ipc(message) + message = message or {} + if not message.json then + return M.utils.string.escape([[{ "request": "IPC requests must be JSON formatted" }]], { ['"'] = '"' }), + suricatta.status.EBADMSG + end + message.msg = message.msg or "" + if message.cmd == message.commands.CONFIG then + suricatta.notify.debug("Got IPC configuration message: %s", message.msg) + if message.json.polling then + M.server.polldelay = tonumber(message.json.polling) or M.server.polldelay + return M.utils.string.escape([[{ "reply": "applied" }]], { ['"'] = '"' }), suricatta.status.OK + end + elseif message.cmd == message.commands.ACTIVATION then + suricatta.notify.debug("Got IPC activation message: %s", message.msg) + return M.utils.string.escape([[{ "request": "inapplicable" }]], { ['"'] = '"' }), suricatta.status.OK + end + suricatta.notify.warn("Got unknown IPC message: %s", message.msg) + return M.utils.string.escape([[{ "request": "unknown" }]], { ['"'] = '"' }), suricatta.status.EBADMSG +end +suricatta.server.register(M.suricatta_funcs.ipc, suricatta.server.IPC) + +return M