From e57ad37808bb9287f35f794a3ec1e3096e750ed9 Mon Sep 17 00:00:00 2001 From: Simon Quigley Date: Mon, 21 Oct 2024 20:40:46 -0500 Subject: [PATCH] Initial commit --- debian/changelog | 5 + debian/control | 30 ++ debian/copyright | 24 ++ debian/rules | 12 + debian/snapd-installation-monitor.install | 2 + debian/snapd-seed-glue.install | 1 + debian/source/format | 1 + .../.qt/QtDeploySupport.cmake | 65 ++++ snapd-installation-monitor/CMakeLists.txt | 12 + snapd-installation-monitor/main.cpp | 76 +++++ .../snapd-installation-monitor.desktop | 8 + snapd-seed-glue/assertions.go | 225 ++++++++++++++ snapd-seed-glue/cleanup.go | 152 ++++++++++ snapd-seed-glue/download.go | 152 ++++++++++ snapd-seed-glue/go.mod | 31 ++ snapd-seed-glue/go.sum | 44 +++ snapd-seed-glue/main.go | 202 +++++++++++++ snapd-seed-glue/process.go | 220 ++++++++++++++ snapd-seed-glue/progress.go | 286 ++++++++++++++++++ snapd-seed-glue/seed.go | 114 +++++++ snapd-seed-glue/utils.go | 150 +++++++++ snapd-seed-glue/validation.go | 108 +++++++ 22 files changed, 1920 insertions(+) create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100755 debian/rules create mode 100644 debian/snapd-installation-monitor.install create mode 100644 debian/snapd-seed-glue.install create mode 100644 debian/source/format create mode 100644 snapd-installation-monitor/.qt/QtDeploySupport.cmake create mode 100644 snapd-installation-monitor/CMakeLists.txt create mode 100644 snapd-installation-monitor/main.cpp create mode 100644 snapd-installation-monitor/snapd-installation-monitor.desktop create mode 100644 snapd-seed-glue/assertions.go create mode 100644 snapd-seed-glue/cleanup.go create mode 100644 snapd-seed-glue/download.go create mode 100644 snapd-seed-glue/go.mod create mode 100644 snapd-seed-glue/go.sum create mode 100644 snapd-seed-glue/main.go create mode 100644 snapd-seed-glue/process.go create mode 100644 snapd-seed-glue/progress.go create mode 100644 snapd-seed-glue/seed.go create mode 100644 snapd-seed-glue/utils.go create mode 100644 snapd-seed-glue/validation.go diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..ccc69df --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +snapd-extra-utils (1.0.0) UNRELEASED; urgency=medium + + * Initial release. (Closes: #nnnn) + + -- Simon Quigley Mon, 14 Oct 2024 13:51:43 -0500 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..5d4dd9a --- /dev/null +++ b/debian/control @@ -0,0 +1,30 @@ +Source: snapd-extra-utils +Section: devel +Priority: optional +Maintainer: Simon Quigley +Rules-Requires-Root: no +Build-Depends: debhelper-compat (= 13), + golang-github-snapcore-snapd-dev (>= 2.62), + golang-go, + golang-golang-x-crypto-dev, + golang-gopkg-yaml.v3-dev, + qt6-base-dev +Standards-Version: 4.7.0 +#Vcs-Browser: https://salsa.debian.org/debian/snapd-extra-utils +#Vcs-Git: https://salsa.debian.org/debian/snapd-extra-utils.git + +Package: snapd-seed-glue +Architecture: any +Depends: snapd, ${misc:Depends}, ${shlibs:Depends} +Description: Installer and pre-seed utilities for snapd + Primarily used in Calamares, snapd-seed-glue updates snap seeds in a given + directory, on a pre-booted system. It handles dependency resolution, delta + downloads, and full snap downloads. + +Package: snapd-installation-monitor +Architecture: any +Depends: snapd, ${misc:Depends}, ${shlibs:Depends} +Description: First-boot snap install notification + When many snaps are preseeded, on first boot the user may be confused if they + can not open one of those snaps. This simple notification informs the user, + disappears when it is complete, and usually is not shown again. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..e47272b --- /dev/null +++ b/debian/copyright @@ -0,0 +1,24 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: snapd-extra-utils +Upstream-Contact: Simon Quigley + +Files: * +Copyright: 2024 Simon Quigley +License: GPL-3.0+ + +License: GPL-3.0+ + This package is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + . + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this package. If not, see . +Comment: + On Debian systems, the complete text of the GNU General + Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..1db62a3 --- /dev/null +++ b/debian/rules @@ -0,0 +1,12 @@ +#!/usr/bin/make -f +export DH_VERBOSE = 1 +export GO111MODULE = off +export GOPATH=/usr/share/gocode +export GOCACHE=$(CURDIR)/.gocache + +%: + dh $@ + +override_dh_auto_build: + (cd snapd-seed-glue && go build -gcflags="all=-N -l" -ldflags="-compressdwarf=false" -o snapd-seed-glue) + (cd snapd-installation-monitor && cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo . && make) diff --git a/debian/snapd-installation-monitor.install b/debian/snapd-installation-monitor.install new file mode 100644 index 0000000..14e3bd0 --- /dev/null +++ b/debian/snapd-installation-monitor.install @@ -0,0 +1,2 @@ +snapd-installation-monitor/snapd-installation-monitor /usr/libexec +snapd-installation-monitor/snapd-installation-monitor.desktop /etc/xdg/autostart diff --git a/debian/snapd-seed-glue.install b/debian/snapd-seed-glue.install new file mode 100644 index 0000000..905fa8c --- /dev/null +++ b/debian/snapd-seed-glue.install @@ -0,0 +1 @@ +snapd-seed-glue/snapd-seed-glue /usr/bin/ diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/snapd-installation-monitor/.qt/QtDeploySupport.cmake b/snapd-installation-monitor/.qt/QtDeploySupport.cmake new file mode 100644 index 0000000..9c7d4bc --- /dev/null +++ b/snapd-installation-monitor/.qt/QtDeploySupport.cmake @@ -0,0 +1,65 @@ +cmake_minimum_required(VERSION 3.16...3.21) + +# These are part of the public API. Projects should use them to provide a +# consistent set of prefix-relative destinations. +if(NOT QT_DEPLOY_BIN_DIR) + set(QT_DEPLOY_BIN_DIR "bin") +endif() +if(NOT QT_DEPLOY_LIB_DIR) + set(QT_DEPLOY_LIB_DIR "lib") +endif() +if(NOT QT_DEPLOY_PLUGINS_DIR) + set(QT_DEPLOY_PLUGINS_DIR "plugins") +endif() +if(NOT QT_DEPLOY_QML_DIR) + set(QT_DEPLOY_QML_DIR "qml") +endif() +if(NOT QT_DEPLOY_TRANSLATIONS_DIR) + set(QT_DEPLOY_TRANSLATIONS_DIR "translations") +endif() +if(NOT QT_DEPLOY_PREFIX) + set(QT_DEPLOY_PREFIX "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}") +endif() +if(QT_DEPLOY_PREFIX STREQUAL "") + set(QT_DEPLOY_PREFIX .) +endif() +if(NOT QT_DEPLOY_IGNORED_LIB_DIRS) + set(QT_DEPLOY_IGNORED_LIB_DIRS "/lib/x86_64-linux-gnu;/lib") +endif() + +# These are internal implementation details. They may be removed at any time. +set(__QT_DEPLOY_SYSTEM_NAME "Linux") +set(__QT_DEPLOY_IS_SHARED_LIBS_BUILD "ON") +set(__QT_DEPLOY_TOOL "GRD") +set(__QT_DEPLOY_IMPL_DIR "/home/tsimonq2/Code/25.04/snapd-extra-utils/snap-installation-monitor/.qt") +set(__QT_DEPLOY_VERBOSE "") +set(__QT_CMAKE_EXPORT_NAMESPACE "Qt6") +set(__QT_DEPLOY_GENERATOR_IS_MULTI_CONFIG "0") +set(__QT_DEPLOY_ACTIVE_CONFIG "Release") +set(__QT_NO_CREATE_VERSIONLESS_FUNCTIONS "") +set(__QT_DEFAULT_MAJOR_VERSION "6") +set(__QT_DEPLOY_QT_ADDITIONAL_PACKAGES_PREFIX_PATH "") +set(__QT_DEPLOY_QT_INSTALL_PREFIX "/usr") +set(__QT_DEPLOY_QT_INSTALL_BINS "lib/qt6/bin") +set(__QT_DEPLOY_QT_INSTALL_DATA "share/qt6") +set(__QT_DEPLOY_QT_INSTALL_LIBEXECS "lib/qt6/libexec") +set(__QT_DEPLOY_QT_INSTALL_PLUGINS "lib/x86_64-linux-gnu/qt6/plugins") +set(__QT_DEPLOY_QT_INSTALL_TRANSLATIONS "share/qt6/translations") +set(__QT_DEPLOY_TARGET_QT_PATHS_PATH "/usr/lib/qt6/bin/qtpaths") +set(__QT_DEPLOY_PLUGINS "") +set(__QT_DEPLOY_MUST_ADJUST_PLUGINS_RPATH "ON") +set(__QT_DEPLOY_USE_PATCHELF "") +set(__QT_DEPLOY_PATCHELF_EXECUTABLE "") +set(__QT_DEPLOY_QT_IS_MULTI_CONFIG_BUILD_WITH_DEBUG "FALSE") +set(__QT_DEPLOY_QT_DEBUG_POSTFIX "") + +# Define the CMake commands to be made available during deployment. +set(__qt_deploy_support_files + "/usr/lib/x86_64-linux-gnu/cmake/Qt6Core/Qt6CoreDeploySupport.cmake" +) +foreach(__qt_deploy_support_file IN LISTS __qt_deploy_support_files) + include("${__qt_deploy_support_file}") +endforeach() + +unset(__qt_deploy_support_file) +unset(__qt_deploy_support_files) diff --git a/snapd-installation-monitor/CMakeLists.txt b/snapd-installation-monitor/CMakeLists.txt new file mode 100644 index 0000000..f9d20ab --- /dev/null +++ b/snapd-installation-monitor/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.5.0) +project(snapd-installation-monitor) + +set(CMAKE_INCLUDE_CURRENT_DIR ON) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 COMPONENTS Widgets DBus REQUIRED) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(snapd-installation-monitor main.cpp) +target_link_libraries(snapd-installation-monitor Qt6::Widgets Qt6::DBus) diff --git a/snapd-installation-monitor/main.cpp b/snapd-installation-monitor/main.cpp new file mode 100644 index 0000000..9b3d5ef --- /dev/null +++ b/snapd-installation-monitor/main.cpp @@ -0,0 +1,76 @@ +// Copyright (C) 2024 Simon Quigley +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 3 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +#include +#include +#include +#include + +int main(int argc, char *argv[]) { + QApplication app(argc, argv); + + // Create and set application icon + QIcon appIcon = QIcon::fromTheme("dialog-information"); + QApplication::setWindowIcon(appIcon); + + // DBus interface to systemd + QDBusInterface systemd("org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + QDBusConnection::systemBus()); + + // Retrieve current status of snapd.seeded.service, which tracks the preseed process + QDBusMessage methodCall = systemd.call("GetUnit", "snapd.seeded.service"); + QDBusObjectPath unitPath = methodCall.arguments().at(0).value(); + QDBusInterface unit("org.freedesktop.systemd1", + unitPath.path(), + "org.freedesktop.systemd1.Unit", + QDBusConnection::systemBus()); + QVariant activeState = unit.property("ActiveState"); + QVariant subState = unit.property("SubState"); + + // System tray icon setup + QSystemTrayIcon trayIcon(appIcon); + trayIcon.setToolTip("Snap Installation Monitor"); + + // Initial message displayed in the system tray + auto showMessage = [&trayIcon]() { + trayIcon.showMessage("Installation Notice", "Finalizing installation of snaps, please wait...", + QSystemTrayIcon::Information, 15000); + }; + + // If the user clicks the system tray icon, display the notification again + QObject::connect(&trayIcon, &QSystemTrayIcon::activated, [&](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger) { + showMessage(); + } + }); + + // Exit immediately if the service is "active (exited)", launch the GUI parts otherwise + if (activeState.toString() == "active" && subState.toString() == "exited") { return 0; } + trayIcon.show(); + showMessage(); + + QTimer timer; + QObject::connect(&timer, &QTimer::timeout, [&unit, &trayIcon]() { + QVariant newState = unit.property("ActiveState"); + QVariant newSubState = unit.property("SubState"); + if (newState.toString() == "active" && newSubState.toString() == "exited") { + trayIcon.hide(); + QApplication::quit(); + } + }); + + timer.start(5000); // Check every 5 seconds + + return app.exec(); +} diff --git a/snapd-installation-monitor/snapd-installation-monitor.desktop b/snapd-installation-monitor/snapd-installation-monitor.desktop new file mode 100644 index 0000000..adb0e39 --- /dev/null +++ b/snapd-installation-monitor/snapd-installation-monitor.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Snap Installation Monitor +Exec=/usr/libexec/snapd-installation-monitor +Icon=dialog-information +Comment=Monitor snapd seeding at startup +X-LXQt-Need-Tray=true +NoDisplay=true diff --git a/snapd-seed-glue/assertions.go b/snapd-seed-glue/assertions.go new file mode 100644 index 0000000..09b1637 --- /dev/null +++ b/snapd-seed-glue/assertions.go @@ -0,0 +1,225 @@ +package main + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/store" + "gopkg.in/yaml.v3" +) + +// downloadAssertions dynamically fetches the necessary assertions and saves them to a file. +// It ensures that the account-key assertion is written first in the .assert file. +func downloadAssertions(storeClient *store.Store, snapInfo *snap.Info, downloadDir string) error { + // Define the path for the assertions file + assertionsPath := filepath.Join(downloadDir, fmt.Sprintf("%s_%d.assert", snapInfo.SuggestedName, snapInfo.Revision.N)) + + // Extract necessary fields from snapInfo + snapSHA := snapInfo.Sha3_384 + snapID := snapInfo.SnapID + publisherID := snapInfo.Publisher.ID + series := "16" // Consider making this dynamic if possible + + // Define assertion types + assertionTypes := map[string]*asserts.AssertionType{ + "snap-revision": asserts.SnapRevisionType, + "snap-declaration": asserts.SnapDeclarationType, + "account-key": asserts.AccountKeyType, + "account": asserts.AccountType, + } + + // Open the assertions file for writing + assertionsFile, err := os.Create(assertionsPath) + if err != nil { + return fmt.Errorf("failed to create assertions file: %w", err) + } + defer assertionsFile.Close() + + // Step 1: Fetch snap-declaration assertion + snapDecl, err := storeClient.Assertion(assertionTypes["snap-declaration"], []string{series, snapID}, nil) + if err != nil { + return fmt.Errorf("failed to fetch snap-declaration assertion for snap %s: %w", snapInfo.SuggestedName, err) + } + + // Step 2: Extract sign-key-sha3-384 from snap-declaration + signKey, ok := snapDecl.Header("sign-key-sha3-384").(string) + if !ok || signKey == "" { + return fmt.Errorf("snap-declaration assertion missing 'sign-key-sha3-384' header for snap %s", snapInfo.SuggestedName) + } + + // Step 3: Fetch account-key assertion using sign-key-sha3-384 (no decoding) + accountKeyAssertion, err := storeClient.Assertion(assertionTypes["account-key"], []string{signKey}, nil) + if err != nil { + return fmt.Errorf("failed to fetch account-key assertion for snap %s: %w", snapInfo.SuggestedName, err) + } + + // Step 4: Fetch account assertion using publisher-id + accountAssertion, err := storeClient.Assertion(assertionTypes["account"], []string{publisherID}, nil) + if err != nil { + return fmt.Errorf("failed to fetch account assertion for snap %s: %w", snapInfo.SuggestedName, err) + } + + // Step 5: Fetch snap-revision assertion + snapSHA384Bytes, err := hex.DecodeString(snapSHA) + if err != nil { + return fmt.Errorf("error decoding SHA3-384 hex string for snap %s: %w", snapInfo.SuggestedName, err) + } + snapSHA384Base64 := base64.RawURLEncoding.EncodeToString(snapSHA384Bytes) + //revisionKey := fmt.Sprintf("%s/global-upload", snapSHA384Base64) + revisionKey := fmt.Sprintf("%s/", snapSHA384Base64) + + snapRevisionAssertion, err := storeClient.Assertion(assertionTypes["snap-revision"], []string{revisionKey}, nil) + if err != nil { + verboseLog("Failed to fetch snap-revision assertion for snap %s: %v", snapInfo.SuggestedName, err) + // Proceeding without snap-revision might be acceptable based on your use-case + } + + // Step 6: Write assertions in the desired order + // 1. account-key + writeAssertion("account-key", accountKeyAssertion, assertionsFile) + + // 2. account + writeAssertion("account", accountAssertion, assertionsFile) + + // 3. snap-declaration + writeAssertion("snap-declaration", snapDecl, assertionsFile) + + // 4. snap-revision (if fetched successfully) + if snapRevisionAssertion != nil { + writeAssertion("snap-revision", snapRevisionAssertion, assertionsFile) + } + + verboseLog("Assertions downloaded and saved to: %s", assertionsPath) + return nil +} + +func writeAssertion(assertionType string, assertion asserts.Assertion, file *os.File) { + fieldOrder := map[string][]string{ + "account-key": { + "type", "authority-id", "revision", "public-key-sha3-384", + "account-id", "name", "since", "body-length", "sign-key-sha3-384", + }, + "account": { + "type", "authority-id", "revision", "account-id", "display-name", + "timestamp", "username", "validation", "sign-key-sha3-384", + }, + "snap-declaration": { + "type", "format", "authority-id", "revision", "series", "snap-id", + "aliases", "auto-aliases", "plugs", "publisher-id", "slots", + "snap-name", "timestamp", "sign-key-sha3-384", + }, + "snap-revision": { + "type", "authority-id", "snap-sha3-384", "developer-id", + "provenance", "snap-id", "snap-revision", "snap-size", + "timestamp", "sign-key-sha3-384", + }, + } + + body := assertion.Body() + bodyLength := len(body) + headers := assertion.Headers() + + // Only write the account assertion if it is not Canonical + if assertionType == "account" { + value, exists := headers["username"] + if exists && value == "canonical" { + return + } + } + + // provenance seems to be a field only available in newer snap revisions + // For snaps published in 2023 or earlier, do not include this field + timestamp, exists := headers["timestamp"] + if assertionType == "snap-revision" && exists { + layout := time.RFC3339 + parsedTime, _ := time.Parse(layout, timestamp.(string)) + thresholdTime := time.Date(2023, time.December, 9, 0, 0, 0, 0, time.UTC) + if parsedTime.Before(thresholdTime) || parsedTime.Equal(thresholdTime) { + delete(headers, "provenance") + } + } + + // Write headers in the specified order + for _, key := range fieldOrder[assertionType] { + value, exists := headers[key] + if !exists || value == "" { + continue + } + if key == "type" { + fmt.Fprintf(file, "%s: %s\n", key, assertionType) + } else if key == "body-length" && bodyLength > 0 { + file.WriteString(fmt.Sprintf("body-length: %d\n", bodyLength)) + continue + } else if isComplexField(key) { + fmt.Fprintf(file, "%s:\n", key) + serializeComplexField(value, file) + } else { + fmt.Fprintf(file, "%s: %s\n", key, value) + } + } + file.WriteString("\n") + + // Write the body if it exists + if bodyLength > 0 { + file.Write(body) + file.WriteString("\n\n") + } + + // Write the signature + _, signature := assertion.Signature() + file.Write(signature) + if assertionType != "snap-revision" { + file.WriteString("\n") + } +} + +func serializeComplexField(value interface{}, file *os.File) { + var buf strings.Builder + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + defer encoder.Close() + + // Encode the value directly + if err := encoder.Encode(value); err != nil { + log.Fatalf("Error encoding YAML: %v", err) + } + + // Write the serialized YAML to the file with proper indentation + lines := strings.Split(buf.String(), "\n") + for _, line := range lines { + if line == "" { + continue + } + line = strings.ReplaceAll(line, `"true"`, "true") + line = strings.ReplaceAll(line, `"false"`, "false") + line = strings.ReplaceAll(line, `'*'`, "*") + // Check for dashes indicating list items + if strings.HasPrefix(strings.TrimSpace(line), "-") && strings.Contains(line, ":") { + before, after, found := strings.Cut(line, "- ") + if found { + file.WriteString(fmt.Sprintf(" %s-\n", before)) + if after != "" { + file.WriteString(fmt.Sprintf(" %s%s\n", before, after)) + } + } else { + file.WriteString(fmt.Sprintf(" %s-\n", line)) + } + } else if strings.TrimSpace(line) != "" { + // For any other non-empty lines, indent them correctly + file.WriteString(fmt.Sprintf(" %s\n", line)) + } + } +} + +// isComplexField checks if a field is complex (nested) in YAML +func isComplexField(key string) bool { + return key == "aliases" || key == "auto-aliases" || key == "plugs" || key == "slots" || key == "allow-installation" || key == "allow-connection" +} diff --git a/snapd-seed-glue/cleanup.go b/snapd-seed-glue/cleanup.go new file mode 100644 index 0000000..832c945 --- /dev/null +++ b/snapd-seed-glue/cleanup.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/snapcore/snapd/store" +) + +// cleanUpFiles removes partial, old, and orphaned snap and assertion files from the download and assertions directories. +func cleanUpFiles(snapsDir, assertionsDir, seedYaml string) { + verboseLog("Starting cleanup process...") + + // Load the seed.yaml data + seedData := loadSeedData(seedYaml) + + // Create a map of valid snap and assertion files based on seed.yaml + validSnaps := make(map[string]bool) + validAssertions := make(map[string]bool) + + // Populate valid snaps and assertions from the seed.yaml data + for _, snap := range seedData.Snaps { + // Ensure correct extraction of revision + revision := extractRevisionFromFile(snap.File) + if revision == "" { + verboseLog("Failed to extract revision from file name: %s", snap.File) + continue + } + snapFileName := fmt.Sprintf("%s_%s.snap", snap.Name, revision) + assertionFileName := fmt.Sprintf("%s_%s.assert", snap.Name, revision) + + validSnaps[snapFileName] = true + validAssertions[assertionFileName] = true + } + + // Log valid snaps and assertions + verboseLog("Valid Snaps: %v", validSnaps) + verboseLog("Valid Assertions: %v", validAssertions) + + // Remove outdated or partial snap files + files, err := os.ReadDir(snapsDir) + if err != nil { + verboseLog("Error reading snaps directory for cleanup: %v", err) + } else { + for _, file := range files { + filePath := filepath.Join(snapsDir, file.Name()) + if strings.HasSuffix(file.Name(), ".partial") || strings.HasSuffix(file.Name(), ".delta") { + verboseLog("Removing partial/delta file: %s\n", filePath) + if err := os.Remove(filePath); err != nil { + verboseLog("Failed to remove file %s: %v", filePath, err) + } else if verbose { + verboseLog("Removed partial/delta file: %s", filePath) + } + } else if strings.HasSuffix(file.Name(), ".snap") { + if !validSnaps[file.Name()] { + verboseLog("Removing outdated or orphaned snap file: %s\n", filePath) + if err := os.Remove(filePath); err != nil { + verboseLog("Failed to remove snap file %s: %v", filePath, err) + } else if verbose { + verboseLog("Removed snap file: %s", filePath) + } + } else { + verboseLog("Snap file %s is valid and retained.\n", file.Name()) + } + } + } + } + + // Remove orphaned assertion files + files, err = os.ReadDir(assertionsDir) + if err != nil { + verboseLog("Error reading assertions directory for cleanup: %v", err) + } else { + for _, file := range files { + filePath := filepath.Join(assertionsDir, file.Name()) + if strings.HasSuffix(file.Name(), ".assert") { + if !validAssertions[file.Name()] { + verboseLog("Removing orphaned assertion file: %s\n", filePath) + if err := os.Remove(filePath); err != nil { + verboseLog("Failed to remove assertion file %s: %v", filePath, err) + } else if verbose { + verboseLog("Removed assertion file: %s", filePath) + } + } else { + verboseLog("Assertion file %s is valid and retained.\n", file.Name()) + } + } + } + } + + verboseLog("Cleanup process completed.") +} + +// removeOrphanedFiles deletes the assertion and snap file corresponding to the removed snap. +func removeOrphanedFiles(snapName string, revision int, assertionsDir string, snapsDir string) { + assertionFilePath := filepath.Join(assertionsDir, fmt.Sprintf("%s_%d.assert", snapName, revision)) + snapFilePath := filepath.Join(snapsDir, fmt.Sprintf("%s_%d.snap", snapName, revision)) + if fileExists(assertionFilePath) { + err := os.Remove(assertionFilePath) + if err != nil { + verboseLog("Failed to remove assertion file %s: %v", assertionFilePath, err) + } else { + verboseLog("Removed assertion file: %s", assertionFilePath) + } + } else { + verboseLog("Assertion file %s does not exist. No action taken.", assertionFilePath) + } + if fileExists(snapFilePath) { + err := os.Remove(snapFilePath) + if err != nil { + verboseLog("Failed to remove snap file %s: %v", snapFilePath, err) + } else { + verboseLog("Removed snap file: %s", snapFilePath) + } + } else { + verboseLog("Snap file %s does not exist. No action taken.", snapFilePath) + } +} + +// cleanUpCurrentSnaps removes snaps from currentSnaps that are not marked as required. +func cleanUpCurrentSnaps(assertionsDir string, snapsDir string) { + var filteredSnaps []*store.CurrentSnap + + for _, snap := range currentSnaps { + if requiredSnaps[snap.InstanceName] { + filteredSnaps = append(filteredSnaps, snap) + } else { + verboseLog("Removing unnecessary snap: %s\n", snap.InstanceName) + removeOrphanedFiles(snap.InstanceName, snap.Revision.N, assertionsDir, snapsDir) + } + } + currentSnaps = filteredSnaps + + // Log the updated currentSnaps + verboseLog("Filtered currentSnaps after cleanup:") + for _, snap := range currentSnaps { + verboseLog("- %s_%d.snap", snap.InstanceName, snap.Revision.N) + } +} + +// removeStateJson removes the state.json file if it exists +func removeStateJson(stateJsonPath string) { + if _, err := os.Stat(stateJsonPath); err == nil { + if err := os.Remove(stateJsonPath); err != nil { + verboseLog("Failed to remove state.json: %v", err) + } else if verbose { + verboseLog("Removed state.json at %s", stateJsonPath) + } + } +} diff --git a/snapd-seed-glue/download.go b/snapd-seed-glue/download.go new file mode 100644 index 0000000..15ed7a4 --- /dev/null +++ b/snapd-seed-glue/download.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "os/exec" + "path/filepath" + "time" + + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/store" +) + +// downloadSnap downloads a snap file with retry logic +func downloadSnap(storeClient *store.Store, snapInfo *snap.Info, downloadPath string) error { + downloadInfo := &snap.DownloadInfo{ + DownloadURL: snapInfo.DownloadURL, + } + + pbar := NewProgressMeter(snapInfo.SuggestedName, snapInfo.Version, false) + progressTracker.UpdateStepProgress(0) + + for attempts := 1; attempts <= 5; attempts++ { + verboseLog("Attempt %d to download snap: %s", attempts, downloadPath) + err := storeClient.Download(ctx, snapInfo.SnapID, downloadPath, downloadInfo, pbar, nil, nil) + if err == nil { + pbar.Finished() + return nil // Successful download + } + if verbose { + verboseLog("Attempt %d to download %s failed: %v", attempts, snapInfo.SuggestedName, err) + } else if progressTracker != nil { + progressTracker.UpdateStepProgress(0) + } + if attempts == 5 { + return fmt.Errorf("snap download failed after 5 attempts: %v", err) + } + } + return fmt.Errorf("snap download failed after 5 attempts") +} + +// downloadSnapDeltaWithRetries downloads the delta file with retry logic and exponential backoff. +func downloadSnapDeltaWithRetries(storeClient *store.Store, delta *snap.DeltaInfo, result *store.SnapActionResult, deltaPath string, maxRetries int, snapName string) error { + if !verbose { + verboseLog("Downloading delta for %s", snapName) + } + var lastErr error + backoff := 1 * time.Second + + for attempts := 1; attempts <= maxRetries; attempts++ { + verboseLog("Attempt %d to download delta: %s", attempts, deltaPath) + err := downloadSnapDelta(storeClient, delta, result, deltaPath) + if err == nil { + return nil + } + lastErr = err + verboseLog("Attempt %d to download delta failed: %v", attempts, err) + time.Sleep(backoff) + backoff *= 2 + } + return fmt.Errorf("delta download failed after %d attempts: %v", maxRetries, lastErr) +} + +// downloadSnapDelta downloads the delta file. +func downloadSnapDelta(storeClient *store.Store, delta *snap.DeltaInfo, result *store.SnapActionResult, deltaPath string) error { + verboseLog("Downloading delta from revision %d to %d from: %s", delta.FromRevision, delta.ToRevision, delta.DownloadURL) + + downloadInfo := &snap.DownloadInfo{ + DownloadURL: delta.DownloadURL, + Size: delta.Size, + Sha3_384: delta.Sha3_384, + } + + // Use the SnapID from the associated SnapActionResult's Info + snapID := result.Info.SnapID + + pbar := NewProgressMeter(result.Info.SuggestedName, result.Info.Version, true) + progressTracker.UpdateStepProgress(0) + + // Download the delta file + if err := storeClient.Download(ctx, snapID, deltaPath, downloadInfo, pbar, nil, nil); err != nil { + progressTracker.UpdateStepProgress(0) + return fmt.Errorf("delta download failed: %v", err) + } + verboseLog("Downloaded %s to %s", delta.DownloadURL, deltaPath) + pbar.Finished() + + return nil +} + +// downloadAndApplySnap handles the downloading and delta application process. +// It returns the snap information and an error if any. +func downloadAndApplySnap(storeClient *store.Store, result *store.SnapActionResult, snapsDir, assertionsDir string, currentSnap *store.CurrentSnap) (*snap.Info, error) { + if result == nil || result.Info == nil { + verboseLog("No updates available for snap. Skipping download and assertions.") + return nil, nil + } + + snapInfo := result.Info + downloadPath := filepath.Join(snapsDir, fmt.Sprintf("%s_%d.snap", snapInfo.SuggestedName, snapInfo.Revision.N)) + + // Check if a delta can be applied + if currentSnap != nil && len(result.Deltas) > 0 { + for _, delta := range result.Deltas { + deltaPath := filepath.Join(snapsDir, fmt.Sprintf("%s_%d_to_%d.delta", snapInfo.SuggestedName, delta.FromRevision, delta.ToRevision)) + if err := downloadSnapDeltaWithRetries(storeClient, &delta, result, deltaPath, 5, snapInfo.SuggestedName); err == nil { + oldSnapPath := filepath.Join(snapsDir, fmt.Sprintf("%s_%d.snap", snapInfo.SuggestedName, delta.FromRevision)) + if fileExists(oldSnapPath) { + if err := applyDelta(oldSnapPath, deltaPath, downloadPath); err == nil { + verboseLog("Delta applied successfully for snap %s", snapInfo.SuggestedName) + // Download assertions after successful snap download + if err := downloadAssertions(storeClient, snapInfo, assertionsDir); err != nil { + return nil, fmt.Errorf("failed to download assertions for snap %s: %w", snapInfo.SuggestedName, err) + } + return snapInfo, nil // Successful delta application + } else { + verboseLog("Failed to apply delta for snap %s: %v", snapInfo.SuggestedName, err) + } + } else { + verboseLog("Old snap file %s does not exist. Cannot apply delta.", oldSnapPath) + } + } else { + verboseLog("Attempt to download delta for snap %s failed: %v", snapInfo.SuggestedName, err) + } + } + } + + // If no delta was applied or no deltas are available, fallback to downloading the full snap + if err := downloadSnap(storeClient, snapInfo, downloadPath); err != nil { + return nil, fmt.Errorf("failed to download snap %s: %w", snapInfo.SuggestedName, err) + } + + // Download assertions after successful snap download + if err := downloadAssertions(storeClient, snapInfo, assertionsDir); err != nil { + return nil, fmt.Errorf("failed to download assertions for snap %s: %w", snapInfo.SuggestedName, err) + } + + verboseLog("Downloaded and applied snap: %s, revision: %d", snapInfo.SuggestedName, snapInfo.Revision.N) + return snapInfo, nil +} + +// applyDelta applies the downloaded delta using xdelta3. +func applyDelta(oldSnapPath, deltaPath, newSnapPath string) error { + verboseLog("Applying delta from %s to %s using %s", oldSnapPath, newSnapPath, deltaPath) + + cmd := exec.Command("xdelta3", "-d", "-s", oldSnapPath, deltaPath, newSnapPath) + output, err := cmd.CombinedOutput() + if err != nil { + verboseLog("xdelta3 output: %s", string(output)) + return fmt.Errorf("failed to apply delta: %v - %s", err, string(output)) + } + return nil +} diff --git a/snapd-seed-glue/go.mod b/snapd-seed-glue/go.mod new file mode 100644 index 0000000..86481a3 --- /dev/null +++ b/snapd-seed-glue/go.mod @@ -0,0 +1,31 @@ +module snapd-seed-glue + +go 1.23.2 + +require ( + github.com/snapcore/snapd v0.0.0-20241012091728-e440fb944764 + golang.org/x/crypto v0.28.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/canonical/go-efilib v0.4.0 // indirect + github.com/canonical/go-sp800.108-kdf v0.0.0-20210314145419-a3359f2d21b9 // indirect + github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3 // indirect + github.com/canonical/go-tpm2 v0.0.0-20210827151749-f80ff5afff61 // indirect + github.com/canonical/tcglog-parser v0.0.0-20210824131805-69fa1e9f0ad2 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/juju/ratelimit v1.0.1 // indirect + github.com/snapcore/go-gettext v0.0.0-20191107141714-82bbea49e785 // indirect + github.com/snapcore/secboot v0.0.0-20240411101434-f3ad7c92552a // indirect + go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/term v0.25.0 // indirect + golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect + gopkg.in/macaroon.v1 v1.0.0-20150121114231-ab3940c6c165 // indirect + gopkg.in/retry.v1 v1.0.3 // indirect + gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + maze.io/x/crypto v0.0.0-20190131090603-9b94c9afe066 // indirect +) diff --git a/snapd-seed-glue/go.sum b/snapd-seed-glue/go.sum new file mode 100644 index 0000000..7391d08 --- /dev/null +++ b/snapd-seed-glue/go.sum @@ -0,0 +1,44 @@ +github.com/canonical/go-efilib v0.4.0 h1:2ee5pvhIZ+g1EO4HxFE/owBgs5Up2g7dw1+Ls9/fiSs= +github.com/canonical/go-efilib v0.4.0/go.mod h1:9b2PNAuPcZsB76x75/uwH99D8CyH/A2y4rq1/+bvplg= +github.com/canonical/go-sp800.108-kdf v0.0.0-20210314145419-a3359f2d21b9 h1:USzKjrfWo/ESzozv2i3OMM7XDgxrZRvaHFrKkIKRtwU= +github.com/canonical/go-sp800.108-kdf v0.0.0-20210314145419-a3359f2d21b9/go.mod h1:Zrs3YjJr+w51u0R/dyLh/oWt/EcBVdLPCVFYC4daW5s= +github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3 h1:oe6fCvaEpkhyW3qAicT0TnGtyht/UrgvOwMcEgLb7Aw= +github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3/go.mod h1:qdP0gaj0QtgX2RUZhnlVrceJ+Qln8aSlDyJwelLLFeM= +github.com/canonical/go-tpm2 v0.0.0-20210827151749-f80ff5afff61 h1:DsyeCtFXqOdukmhPOunohjSlyxDHTqWSW1O4rD9N3L8= +github.com/canonical/go-tpm2 v0.0.0-20210827151749-f80ff5afff61/go.mod h1:vG41hdbBjV4+/fkubTT1ENBBqSkLwLr7mCeW9Y6kpZY= +github.com/canonical/tcglog-parser v0.0.0-20210824131805-69fa1e9f0ad2 h1:CbwVq64ruNLx/S3XA0LO6QMsw6Vc2inK+RcS6D2c4Ns= +github.com/canonical/tcglog-parser v0.0.0-20210824131805-69fa1e9f0ad2/go.mod h1:QoW2apR2tBl6T/4czdND/EHjL1Ia9cCmQnIj9Xe0Kt8= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/juju/ratelimit v1.0.1 h1:+7AIFJVQ0EQgq/K9+0Krm7m530Du7tIz0METWzN0RgY= +github.com/juju/ratelimit v1.0.1/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= +github.com/snapcore/go-gettext v0.0.0-20191107141714-82bbea49e785 h1:PaunR+BhraKSLxt2awQ42zofkP+NKh/VjQ0PjIMk/y4= +github.com/snapcore/go-gettext v0.0.0-20191107141714-82bbea49e785/go.mod h1:D3SsWAXK7wCCBZu+Vk5hc1EuKj/L3XN1puEMXTU4LrQ= +github.com/snapcore/secboot v0.0.0-20240411101434-f3ad7c92552a h1:yzzVi0yUosDYkjSQqGZNVtaVi+6yNFLiF0erKHlBbdo= +github.com/snapcore/secboot v0.0.0-20240411101434-f3ad7c92552a/go.mod h1:72paVOkm4sJugXt+v9ItmnjXgO921D8xqsbH2OekouY= +github.com/snapcore/snapd v0.0.0-20241012091728-e440fb944764 h1:RBbGB8WMdKgB7FCPXuUIBns8L+LezNsbvYNYXrG752w= +github.com/snapcore/snapd v0.0.0-20241012091728-e440fb944764/go.mod h1:c9j4npxTEGjMy6E/7ocAazCMR+82ZWTh/o+5Ic7ilPc= +go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 h1:A/5uWzF44DlIgdm/PQFwfMkW0JX+cIcQi/SwLAmZP5M= +go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0= +golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gopkg.in/macaroon.v1 v1.0.0-20150121114231-ab3940c6c165 h1:85xqOSyTpSzplW7fyO9bOZpSsemJc9UKzEQR2L4k32k= +gopkg.in/macaroon.v1 v1.0.0-20150121114231-ab3940c6c165/go.mod h1:PABpHZvxAbIuSYTPWJdQsNu0mtx+HX/1NIm3IT95IX0= +gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs= +gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g= +gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs= +gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maze.io/x/crypto v0.0.0-20190131090603-9b94c9afe066 h1:UrD21H1Ue5Nl8f2x/NQJBRdc49YGmla3mRStinH8CCE= +maze.io/x/crypto v0.0.0-20190131090603-9b94c9afe066/go.mod h1:DEvumi+swYmlKxSlnsvPwS15tRjoypCCeJFXswU5FfQ= diff --git a/snapd-seed-glue/main.go b/snapd-seed-glue/main.go new file mode 100644 index 0000000..b989451 --- /dev/null +++ b/snapd-seed-glue/main.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "flag" + "log" + "fmt" + "path/filepath" + "strings" + + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/store" +) + +// Seed structure for seed.yaml +type seed struct { + Snaps []struct { + Name string `yaml:"name"` + Channel string `yaml:"channel"` + File string `yaml:"file"` + } `yaml:"snaps"` +} + +var ( + ctx = context.Background() + storeClient *store.Store + verbose bool + currentSnaps []*store.CurrentSnap + requiredSnaps map[string]bool + processedSnaps = make(map[string]bool) + snapSizeMap = make(map[string]float64) + totalSnapSize float64 +) + +type SnapInfo struct { + InstanceName string + SnapID string + Revision snap.Revision +} + +func main() { + // Override the default plug slot sanitizer + snap.SanitizePlugsSlots = sanitizePlugsSlots + + // Initialize progress reporting + InitProgress() + totalSnapSize = 0 + + // Initialize the store client + storeClient = store.New(nil, nil) + + // Parse command-line flags + var seedDirectory string + flag.StringVar(&seedDirectory, "seed", "/var/lib/snapd/seed", "Specify the seed directory") + flag.BoolVar(&verbose, "verbose", false, "Enable verbose output") + flag.Parse() + if !verbose { + fmt.Printf("2\tLoading existing snaps...\n") + } + + // Define directories based on the seed directory + snapsDir := filepath.Join(seedDirectory, "snaps") + assertionsDir := filepath.Join(seedDirectory, "assertions") + seedYaml := filepath.Join(seedDirectory, "seed.yaml") + + // Setup directories and seed.yaml + initializeDirectories(snapsDir, assertionsDir) + initializeSeedYaml(seedYaml) + + // Load existing snaps from seed.yaml + existingSnapsInYaml := loadExistingSnaps(seedYaml) + + // Populate currentSnaps based on existing snaps + for snapName := range existingSnapsInYaml { + snapInfo, err := getCurrentSnapInfo(assertionsDir, snapName) + if err != nil { + verboseLog("Failed to get info for existing snap %s: %v", snapName, err) + continue + } + currentSnaps = append(currentSnaps, snapInfo) + } + + // Process essential snaps + requiredSnaps = map[string]bool{"snapd": true, "bare": true} + for _, arg := range flag.Args() { + requiredSnaps[arg] = true + } + if !verbose { + fmt.Printf("4\tFetching information from the Snap Store...\n") + } + + // Collect snaps to process + snapsToProcess, err := collectSnapsToProcess(snapsDir, assertionsDir) + if err != nil { + log.Fatalf("Failed to collect snaps to process: %v", err) + } + + progressTracker.Finish("Finished collecting snap info") + + // Calculate the number of snaps to download + totalSnaps := len(snapsToProcess) + if totalSnaps == 0 { + verboseLog("No snaps to process.") + } else { + verboseLog("Total snaps to download: %d", totalSnaps) + } + + // Initialize variables to track download progress + completedSnaps := 0 + + // Update "Downloading snaps" step to 0% + progressTracker.UpdateStepProgress(0) + + // Process all the snaps that need updates + for _, snapDetails := range snapsToProcess { + if err := processSnap(snapDetails, snapsDir, assertionsDir); err != nil { + log.Fatalf("Failed to process snap %s: %v", snapDetails.InstanceName, err) + } + completedSnaps++ + + progressTracker.UpdateStepProgress(-1) + } + + // Mark "Downloading snaps" as complete + if totalSnaps > 0 { + progressTracker.Finish("Downloading snaps completed") + } else { + // If no snaps to download, skip to finalizing + progressTracker.NextStep() + } + + // Remove unnecessary snaps after processing dependencies + cleanUpCurrentSnaps(assertionsDir, snapsDir) + + // Update seed.yaml with the current required snaps + if err := updateSeedYaml(snapsDir, seedYaml, currentSnaps); err != nil { + log.Fatalf("Failed to update seed.yaml: %v", err) + } + + // Perform cleanup and validation tasks + removeStateJson(filepath.Join(seedDirectory, "..", "state.json")) + ensureAssertions(assertionsDir) + if err := validateSeed(seedYaml); err != nil { + log.Fatalf("Seed validation failed: %v", err) + } + cleanUpFiles(snapsDir, assertionsDir, seedYaml) + + // Mark "Finalizing" as complete + if progressTracker != nil { + progressTracker.Finish("Cleanup and validation completed") + } +} + +// collectSnapsToProcess collects all snaps and their dependencies, returning only those that need updates +func collectSnapsToProcess(snapsDir, assertionsDir string) ([]SnapDetails, error) { + var snapsToProcess []SnapDetails + + for snapEntry := range requiredSnaps { + // Extract channel if specified, default to "stable" + parts := strings.SplitN(snapEntry, "=", 2) + channel := "stable" + if len(parts) == 2 { + channel = parts[1] + } + snapName := parts[0] + + // Collect snap dependencies and their statuses + snapList, err := collectSnapDependencies(snapName, channel, snapsDir, assertionsDir) + if err != nil { + return nil, err + } + + // Append only those snaps that need updates + for _, snapDetails := range snapList { + verboseLog("Processing snap: %s with result: %v", snapDetails.InstanceName, snapDetails.Result) + if len(snapDetails.Result.Deltas) > 0 { + for _, delta := range snapDetails.Result.Deltas { + snapSize := float64(delta.Size) + snapSizeMap[snapDetails.Result.Info.SuggestedName] = snapSize + totalSnapSize += snapSize + } + } else { + snapSize := float64(snapDetails.Result.Info.Size) + snapSizeMap[snapDetails.Result.Info.SuggestedName] = snapSize + totalSnapSize += snapSize + } + snapsToProcess = append(snapsToProcess, snapDetails) + } + } + + return snapsToProcess, nil +} + +// sanitizePlugsSlots is a placeholder function to sanitize plug slots in snap.Info +func sanitizePlugsSlots(info *snap.Info) {} + +// verboseLog logs messages only when verbose mode is enabled +func verboseLog(format string, v ...interface{}) { + if verbose { + log.Printf(format, v...) + } +} diff --git a/snapd-seed-glue/process.go b/snapd-seed-glue/process.go new file mode 100644 index 0000000..e320763 --- /dev/null +++ b/snapd-seed-glue/process.go @@ -0,0 +1,220 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/store" +) + +// SnapDetails struct remains unchanged +type SnapDetails struct { + InstanceName string + Channel string + CurrentSnap *store.CurrentSnap + Result *store.SnapActionResult +} + +// collectSnapDependencies collects all dependencies for a given snap, marking them as requiredSnaps regardless of whether they need updates. +func collectSnapDependencies(snapName, channel, snapsDir, assertionsDir string) ([]SnapDetails, error) { + var snapDetailsList []SnapDetails + + if processedSnaps[snapName] { + verboseLog("Snap %s has already been processed. Skipping.", snapName) + return snapDetailsList, nil // Added 0 for int64 + } + + oldSnapPath, oldSnap := findPreviousSnap(snapsDir, assertionsDir, snapName) + + var result *store.SnapActionResult + var err error + + // Fetch or refresh snap information + if oldSnap == nil || oldSnap.SnapID == "" || oldSnap.Revision.N == 0 { + result, err = fetchOrRefreshSnapInfo(snapName, nil, channel) + if err != nil { + return nil, err // Added 0 for int64 + } + } else { + result, err = fetchOrRefreshSnapInfo(snapName, oldSnap, channel) + if err != nil && strings.Contains(err.Error(), "snap has no updates available") { + result, err = fetchOrRefreshSnapInfo(snapName, nil, channel) + if err != nil { + return nil, err // Added 0 for int64 + } + } + } + + if result == nil || result.Info == nil || result.Info.SnapID == "" || result.Info.Revision.N == 0 { + return nil, fmt.Errorf("invalid snap information returned for %s: SnapID or Revision is missing", snapName) // Added 0 for int64 + } + + info := result.Info + newSnap := &store.CurrentSnap{ + InstanceName: snapName, + SnapID: info.SnapID, + Revision: snap.Revision{N: info.Revision.N}, + } + snapInCurrentSnaps, oldRevision := isSnapInCurrentSnaps(snapName) + if snapInCurrentSnaps { + removeSnapFromCurrentSnaps(snapName, oldRevision) + } + currentSnaps = append(currentSnaps, newSnap) + processedSnaps[snapName] = true + + needsUpdate := (oldSnapPath == "" || oldSnap.Revision.N < info.Revision.N) + + if needsUpdate { + snapDetailsList = append(snapDetailsList, SnapDetails{ + InstanceName: snapName, + Channel: channel, + CurrentSnap: newSnap, + Result: result, + }) + } else { + // Mark the snap as required even if no update is needed + requiredSnaps[snapName] = true + } + + // Safely handle dependencies + tracker := snap.SimplePrereqTracker{} + missingPrereqs := tracker.MissingProviderContentTags(info, nil) + for prereq := range missingPrereqs { + if !processedSnaps[prereq] { + verboseLog("Collecting dependencies for prerequisite snap: %s for %s", prereq, snapName) + prereqDetails, err := collectSnapDependencies(prereq, "stable", snapsDir, assertionsDir) + if err != nil { + // Additional logging for dependency resolution issues + verboseLog("Failed to collect dependencies for prerequisite %s for snap %s: %v", prereq, snapName, err) + return nil, fmt.Errorf("failed to collect dependencies for prerequisite %s for snap %s: %v", prereq, snapName, err) // Added 0 for int64 + } + snapDetailsList = append(snapDetailsList, prereqDetails...) + } + } + + // Also handle base snaps safely + if info.Base != "" && !processedSnaps[info.Base] { + verboseLog("Collecting dependencies for base snap: %s for %s", info.Base, snapName) + baseDetails, err := collectSnapDependencies(info.Base, "stable", snapsDir, assertionsDir) + if err != nil { + verboseLog("Failed to collect dependencies for base snap %s for snap %s: %v", info.Base, snapName, err) + return nil, fmt.Errorf("failed to collect dependencies for base snap %s for snap %s: %v", info.Base, snapName, err) // Added 0 for int64 + } + snapDetailsList = append(snapDetailsList, baseDetails...) + } + + return snapDetailsList, nil +} + +// processSnap handles the downloading and applying of a snap if updates are available. +// It gracefully handles the "no updates available" scenario and ensures dependencies are marked as required. +func processSnap(snapDetails SnapDetails, snapsDir, assertionsDir string) error { + verboseLog("Processing snap: %s on channel: %s", snapDetails.InstanceName, snapDetails.Channel) + + // Proceed with downloading the snap (either full or delta) using downloadAndApplySnap + snapInfo, err := downloadAndApplySnap(storeClient, snapDetails.Result, snapsDir, assertionsDir, snapDetails.CurrentSnap) + if err != nil { + return fmt.Errorf("failed to download snap %s: %w", snapDetails.InstanceName, err) + } + + // Mark the snap as required after successful download and application + requiredSnaps[snapDetails.InstanceName] = true + verboseLog("Downloaded and applied snap: %s, revision: %d", snapInfo.SuggestedName, snapInfo.Revision.N) + return nil +} + +// fetchOrRefreshSnapInfo retrieves snap information and returns the SnapActionResult including deltas. +func fetchOrRefreshSnapInfo(snapName string, currentSnap *store.CurrentSnap, channel string) (*store.SnapActionResult, error) { + var actions []*store.SnapAction + var includeSnap []*store.CurrentSnap + if currentSnap != nil { + verboseLog("Crafting refresh SnapAction for %s", snapName) + actions = append(actions, &store.SnapAction{ + Action: "refresh", + SnapID: currentSnap.SnapID, + InstanceName: snapName, + Channel: channel, + }) + includeSnap = []*store.CurrentSnap{currentSnap} + } else { + verboseLog("Crafting install SnapAction for %s", snapName) + actions = append(actions, &store.SnapAction{ + Action: "install", + InstanceName: snapName, + Channel: channel, + }) + includeSnap = []*store.CurrentSnap{} + } + + results, _, err := storeClient.SnapAction(ctx, includeSnap, actions, nil, nil, nil) + if err != nil { + verboseLog("SnapAction error for %s: %v", snapName, err) + if strings.Contains(err.Error(), "snap has no updates available") && currentSnap != nil { + return nil, err + } + return nil, fmt.Errorf("snap action failed for %s: %w", snapName, err) + } + + if len(results) == 0 || results[0].Info == nil { + return nil, fmt.Errorf("no snap info returned for snap %s", snapName) + } + + result := &results[0] + info := result.Info + + // Validate necessary fields in the snap information + if info.SnapID == "" || info.Revision.N == 0 { + return nil, fmt.Errorf("invalid snap information for %s: SnapID or Revision is missing", snapName) + } + + verboseLog("Fetched latest snap info for %s: SnapID: %s, Revision: %d", snapName, info.SnapID, info.Revision.N) + return result, nil +} + +// findPreviousSnap locates the previous snap revision in the downloads directory. +func findPreviousSnap(downloadDir, assertionsDir, snapName string) (string, *store.CurrentSnap) { + var currentSnap store.CurrentSnap + files, err := os.ReadDir(downloadDir) + if err != nil { + verboseLog("Error reading directory: %v", err) + return "", nil + } + + var latestRevision int + var latestSnapPath string + + for _, file := range files { + if strings.HasPrefix(file.Name(), snapName+"_") && strings.HasSuffix(file.Name(), ".snap") { + revisionStr := extractRevisionFromFile(file.Name()) + if revisionStr == "" { + verboseLog("Failed to extract revision from file name: %s", file.Name()) + continue + } + revision, err := strconv.Atoi(revisionStr) + if err != nil { + verboseLog("Failed to parse revision number for file %s: %v", file.Name(), err) + continue + } + verboseLog("Found %s with revision %d", file.Name(), revision) + + if revision > latestRevision { + latestRevision = revision + latestSnapPath = filepath.Join(downloadDir, file.Name()) + + // Parse the corresponding assertion file + assertFilePath := filepath.Join(assertionsDir, strings.Replace(file.Name(), ".snap", ".assert", 1)) + currentSnap = parseSnapInfo(assertFilePath, snapName) + currentSnap.Revision.N = revision + } + } + } + + if latestSnapPath != "" { + return latestSnapPath, ¤tSnap + } + return "", nil +} diff --git a/snapd-seed-glue/progress.go b/snapd-seed-glue/progress.go new file mode 100644 index 0000000..68632f3 --- /dev/null +++ b/snapd-seed-glue/progress.go @@ -0,0 +1,286 @@ +package main + +import ( + "fmt" + "sync" + "github.com/snapcore/snapd/progress" +) + +var ( + progressReporter ProgressReporter + progressTracker *ProgressTracker + globalDownloaded float64 + globalMu sync.Mutex + lastReported int +) + +type ProgressReporter interface { + Report(percentage int, status string) +} + +// VerboseProgressReporter formats progress updates +type VerboseProgressReporter struct{} + +func (v *VerboseProgressReporter) Report(percentage int, status string) { + fmt.Printf("%d\t%s\n", percentage, status) +} + +// ProgressMeter tracks the download progress and implements the progress.Meter interface +type ProgressMeter struct { + currentBytes float64 + isDelta bool + mu sync.Mutex + snapName string + snapVersion string + totalSize float64 +} + +// Ensure ProgressMeter implements the progress.Meter interface +var _ progress.Meter = (*ProgressMeter)(nil) + +// NewProgressMeter initializes a new ProgressMeter instance with the snap name +func NewProgressMeter(snapName string, snapVersion string, isDelta bool) *ProgressMeter { + return &ProgressMeter{ + isDelta: isDelta, + snapName: snapName, + snapVersion: snapVersion, + totalSize: snapSizeMap[snapName], + } +} + +// Start initializes the progress meter with the total size +func (pm *ProgressMeter) Start(label string, total float64) { + pm.mu.Lock() + defer pm.mu.Unlock() + pm.totalSize = total + + // Update global total size + globalMu.Lock() + globalMu.Unlock() +} + +// Set updates the progress based on the current value representing the number of bytes downloaded +func (pm *ProgressMeter) Set(value float64) { + pm.mu.Lock() + defer pm.mu.Unlock() + + delta := value - pm.currentBytes + pm.currentBytes = value + + // Update global downloaded bytes + globalMu.Lock() + globalDownloaded += delta + globalMu.Unlock() + + reportGlobalProgress(pm.snapName, pm.snapVersion, pm.isDelta) +} + +// SetTotal sets the total size for the ProgressMeter +func (pm *ProgressMeter) SetTotal(total float64) { + pm.mu.Lock() + defer pm.mu.Unlock() + pm.totalSize = total +} + +// Finished marks the progress as complete +func (pm *ProgressMeter) Finished() { + pm.mu.Lock() + defer pm.mu.Unlock() + + delta := pm.totalSize - pm.currentBytes + pm.currentBytes = pm.totalSize + + // Update global downloaded bytes + globalMu.Lock() + globalDownloaded += delta + globalMu.Unlock() + + reportGlobalProgress(pm.snapName, pm.snapVersion, pm.isDelta) +} + +// Write handles byte data to update progress based on the size of the data written +func (pm *ProgressMeter) Write(p []byte) (n int, err error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + // Calculate the delta and update the current bytes + delta := float64(len(p)) + pm.currentBytes += delta + + // Update global downloaded bytes + globalMu.Lock() + globalDownloaded += delta + globalMu.Unlock() + + reportGlobalProgress(pm.snapName, pm.snapVersion, pm.isDelta) + + return len(p), nil +} + +// reportGlobalProgress calculates and formats the overall progress percentage +func reportGlobalProgress(snapName string, snapVersion string, isDelta bool) { + globalMu.Lock() + defer globalMu.Unlock() + + if totalSnapSize == 0 { + return + } + // Calculate the percentage within the range of 10 to 90 + percentage := int((globalDownloaded / totalSnapSize) * 80) + 10 + + // Only print if there's a change in percentage to reduce output + if percentage != lastReported { + lastReported = percentage + if isDelta { + progressString := fmt.Sprintf("Downloading delta for snap %s %s", snapName, snapVersion) + progressReporter.Report(percentage, progressString) + } else { + progressString := fmt.Sprintf("Downloading snap %s %s", snapName, snapVersion) + progressReporter.Report(percentage, progressString) + } + } +} + +// Spin shows indefinite activity; not used in this implementation +func (pm *ProgressMeter) Spin(msg string) { + fmt.Printf("Spin: %s\n", msg) +} + +// Notify formats notifications about the progress +func (pm *ProgressMeter) Notify(message string) { + fmt.Printf("Notification: %s\n", message) +} + +// ProgressTracker manages multiple steps of progress +type ProgressTracker struct { + totalWeight int + completedWeight float64 + mu sync.Mutex + reporter ProgressReporter + steps []*WeightedStep + currentStep int +} + +// WeightedStep represents a step in a multi-step progress tracker +type WeightedStep struct { + Weight int + Progress float64 + Status string +} + +// NewProgressTracker creates a new instance of ProgressTracker +func NewProgressTracker(reporter ProgressReporter) *ProgressTracker { + return &ProgressTracker{ + reporter: reporter, + steps: []*WeightedStep{}, + } +} + +// AddStep adds a new step to the progress tracker +func (pt *ProgressTracker) AddStep(weight int, status string) { + pt.mu.Lock() + defer pt.mu.Unlock() + pt.steps = append(pt.steps, &WeightedStep{ + Weight: weight, + Status: status, + }) + pt.totalWeight += weight +} + +// Start initializes the first step of the tracker +func (pt *ProgressTracker) Start() { + pt.mu.Lock() + defer pt.mu.Unlock() + if len(pt.steps) > 0 { + pt.currentStep = 0 + pt.steps[pt.currentStep].Progress = 0 + pt.reportProgress() + } +} + +// UpdateStepProgress updates the current step's progress +func (pt *ProgressTracker) UpdateStepProgress(progress float64) { + pt.mu.Lock() + defer pt.mu.Unlock() + if pt.currentStep >= len(pt.steps) { + return + } + step := pt.steps[pt.currentStep] + if progress < 0 { + progress = float64(int((globalDownloaded / totalSnapSize) * float64(step.Weight))) + } else if progress < step.Progress { + return + } + // Adjust delta calculation + delta := (progress - step.Progress) + pt.completedWeight += delta + step.Progress = progress + //pt.reportProgress() +} + +func (pt *ProgressTracker) Finish(status string) { + pt.mu.Lock() + defer pt.mu.Unlock() + if len(pt.steps) == 0 || pt.currentStep >= len(pt.steps) { + return + } + step := pt.steps[pt.currentStep] + if step.Progress < 100.0 { + // Corrected calculation to prevent exceeding 100% + remainingProgress := 100.0 - step.Progress + delta := (remainingProgress * float64(step.Weight)) / 100.0 + pt.completedWeight += delta + step.Progress = 100.0 + } + percentage := pt.calculatePercentage() + pt.reporter.Report(percentage, status) + if pt.currentStep < len(pt.steps)-1 { + pt.currentStep++ + pt.steps[pt.currentStep].Progress = 0 + pt.reportProgress() + } +} + +// NextStep moves to the next step in the progress tracker +func (pt *ProgressTracker) NextStep() { + pt.mu.Lock() + defer pt.mu.Unlock() + if pt.currentStep < len(pt.steps)-1 { + pt.currentStep++ + pt.steps[pt.currentStep].Progress = 0 + pt.reportProgress() + } +} + +// calculatePercentage calculates the overall progress as a percentage +func (pt *ProgressTracker) calculatePercentage() int { + if pt.totalWeight == 0 { + return 0 + } + // Calculate percentage accurately and scale within 0-99 range + percentage := int((pt.completedWeight / float64(pt.totalWeight)) * 100) + if percentage > 99 { + percentage = 99 + } + return percentage +} + +// reportProgress reports the current progress percentage to the reporter +func (pt *ProgressTracker) reportProgress() { + percentage := pt.calculatePercentage() + status := pt.steps[pt.currentStep].Status + if percentage != lastReported { + pt.reporter.Report(percentage, status) + lastReported = percentage + } +} + +// InitProgress initializes the global progress tracker and sets up steps +func InitProgress() { + progressReporter = &VerboseProgressReporter{} + progressTracker = NewProgressTracker(progressReporter) + progressTracker.AddStep(10, "Initialization") + progressTracker.AddStep(80, "Downloading snaps") + progressTracker.AddStep(10, "Verifying snaps") + progressTracker.Start() +} diff --git a/snapd-seed-glue/seed.go b/snapd-seed-glue/seed.go new file mode 100644 index 0000000..ce7979a --- /dev/null +++ b/snapd-seed-glue/seed.go @@ -0,0 +1,114 @@ +package main + +import ( + "fmt" + "io/ioutil" + "log" + "os" + + "gopkg.in/yaml.v3" + "github.com/snapcore/snapd/store" +) + +// initializeSeedYaml ensures that seed.yaml exists; if not, creates it. +func initializeSeedYaml(seedYaml string) { + if _, err := os.Stat(seedYaml); os.IsNotExist(err) { + file, err := os.Create(seedYaml) + if err != nil { + log.Fatalf("Failed to create seed.yaml: %v", err) + } + defer file.Close() + file.WriteString("snaps:\n") + } +} + +// loadSeedData loads seed data from seed.yaml +func loadSeedData(seedYaml string) seed { + file, err := ioutil.ReadFile(seedYaml) + if err != nil { + log.Fatalf("Failed to read seed.yaml: %v", err) + } + + var seedData seed + if err := yaml.Unmarshal(file, &seedData); err != nil { + log.Fatalf("Failed to parse seed.yaml: %v", err) + } + + return seedData +} + +// loadExistingSnaps loads snaps from seed.yaml into a map +func loadExistingSnaps(seedYaml string) map[string]bool { + file, err := ioutil.ReadFile(seedYaml) + if err != nil { + log.Fatalf("Failed to read seed.yaml: %v", err) + } + + var seedData seed + if err := yaml.Unmarshal(file, &seedData); err != nil { + log.Fatalf("Failed to parse seed.yaml: %v", err) + } + + existing := make(map[string]bool) + for _, snap := range seedData.Snaps { + existing[snap.Name] = true + verboseLog("Found %s in seed.yaml\n", snap.Name) + } + return existing +} + +// updateSeedYaml updates the seed.yaml file with the current required snaps +func updateSeedYaml(snapsDir, seedYaml string, currentSnaps []*store.CurrentSnap) error { + // Log the snaps to be written + verboseLog("CurrentSnaps to be written to seed.yaml:") + for _, snapInfo := range currentSnaps { + verboseLog("- %s_%d.snap", snapInfo.InstanceName, snapInfo.Revision.N) + } + + // Load existing seed data + file, err := ioutil.ReadFile(seedYaml) + if err != nil { + return fmt.Errorf("failed to read seed.yaml: %w", err) + } + + var seedData seed + if err := yaml.Unmarshal(file, &seedData); err != nil { + return fmt.Errorf("failed to parse seed.yaml: %w", err) + } + + // Clear existing snaps + seedData.Snaps = []struct { + Name string `yaml:"name"` + Channel string `yaml:"channel"` + File string `yaml:"file"` + }{} + + // Populate seedData with currentSnaps + for _, snapInfo := range currentSnaps { + snapFileName := fmt.Sprintf("%s_%d.snap", snapInfo.InstanceName, snapInfo.Revision.N) + snapData := struct { + Name string `yaml:"name"` + Channel string `yaml:"channel"` + File string `yaml:"file"` + }{ + Name: snapInfo.InstanceName, + Channel: "stable", // Assuming 'stable' channel; modify as needed + File: snapFileName, + } + seedData.Snaps = append(seedData.Snaps, snapData) + } + + // Marshal the updated seedData back to YAML + updatedYAML, err := yaml.Marshal(&seedData) + if err != nil { + return fmt.Errorf("failed to marshal updated seed data: %w", err) + } + + // Write the updated YAML back to seed.yaml + if err := ioutil.WriteFile(seedYaml, updatedYAML, 0644); err != nil { + return fmt.Errorf("failed to write updated seed.yaml: %w", err) + } + + verboseLog("Updated seed.yaml with current snaps.") + return nil +} diff --git a/snapd-seed-glue/utils.go b/snapd-seed-glue/utils.go new file mode 100644 index 0000000..0aa2b0c --- /dev/null +++ b/snapd-seed-glue/utils.go @@ -0,0 +1,150 @@ +package main + +import ( + "bufio" + "encoding/hex" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/store" + "golang.org/x/crypto/sha3" +) + +// verifyChecksum calculates the SHA3-384 checksum of a file and compares it with the expected checksum. +func verifyChecksum(filePath, expectedChecksum string) (bool, error) { + file, err := os.Open(filePath) + if err != nil { + return false, fmt.Errorf("failed to open file for checksum verification: %w", err) + } + defer file.Close() + + hash := sha3.New384() + if _, err := io.Copy(hash, file); err != nil { + return false, fmt.Errorf("failed to calculate checksum: %w", err) + } + + calculatedChecksum := hex.EncodeToString(hash.Sum(nil)) + return strings.EqualFold(calculatedChecksum, expectedChecksum), nil +} + +// extractRevisionFromFile extracts the revision number from a file name by splitting at the last underscore. +func extractRevisionFromFile(fileName string) string { + lastUnderscore := strings.LastIndex(fileName, "_") + if lastUnderscore == -1 { + return "" + } + revisionWithSuffix := fileName[lastUnderscore+1:] + // Handle both .snap and .assert suffixes + if strings.HasSuffix(revisionWithSuffix, ".snap") { + return strings.TrimSuffix(revisionWithSuffix, ".snap") + } else if strings.HasSuffix(revisionWithSuffix, ".assert") { + return strings.TrimSuffix(revisionWithSuffix, ".assert") + } + return revisionWithSuffix +} + +// fileExists checks if a file exists and is not a directory before we use it +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +// parseSnapInfo extracts snap details from the assertion file and returns the snap's current revision. +func parseSnapInfo(assertFilePath, snapName string) store.CurrentSnap { + currentSnap := store.CurrentSnap{InstanceName: snapName} + file, err := os.Open(assertFilePath) + if err != nil { + verboseLog("Failed to read assertion file: %v\n", err) + return currentSnap + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "snap-id:") { + currentSnap.SnapID = strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) + } + if strings.HasPrefix(line, "snap-revision:") { + revisionStr := strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) + revision, err := strconv.Atoi(revisionStr) + if err != nil { + verboseLog("Failed to parse snap-revision for snap %s: %v", snapName, err) + continue + } + currentSnap.Revision.N = revision + } + } + + // Validate that required fields are present + if currentSnap.SnapID == "" || currentSnap.Revision.N == 0 { + verboseLog("Incomplete snap info in assertion file: %s", assertFilePath) + } + + return currentSnap +} + +// isSnapInCurrentSnaps checks if a snap is already in currentSnaps +func isSnapInCurrentSnaps(snapName string) (bool, snap.Revision) { + for _, snap := range currentSnaps { + if snap.InstanceName == snapName { + return true, snap.Revision + } + } + return false, snap.Revision{} +} + +func removeSnapFromCurrentSnaps(snapName string, revision snap.Revision) { + for i := 0; i < len(currentSnaps); i++ { + if currentSnaps[i].Revision == revision { + // Remove the element at index i + currentSnaps = append(currentSnaps[:i], currentSnaps[i+1:]...) + return + } + } +} + +// initializeDirectories ensures that the snaps and assertions directories exist +func initializeDirectories(snapsDir, assertionsDir string) { + if err := os.MkdirAll(snapsDir, 0755); err != nil { + log.Fatalf("Failed to create snaps directory: %v", err) + } + if err := os.MkdirAll(assertionsDir, 0755); err != nil { + log.Fatalf("Failed to create assertions directory: %v", err) + } +} + +// getCurrentSnapInfo retrieves current snap information from assertions +func getCurrentSnapInfo(assertionsDir, snapName string) (*store.CurrentSnap, error) { + assertionFiles, err := filepath.Glob(filepath.Join(assertionsDir, fmt.Sprintf("%s_*.assert", snapName))) + if err != nil || len(assertionFiles) == 0 { + return nil, fmt.Errorf("no assertion file found for snap: %s", snapName) + } + + assertionFile := assertionFiles[0] + currentSnap := parseSnapInfo(assertionFile, snapName) + if currentSnap.SnapID == "" || currentSnap.Revision.N == 0 { + return nil, fmt.Errorf("incomplete snap info in assertion file for snap: %s", snapName) + } + verboseLog("Found snap info for %s: SnapID: %s, Revision: %d", snapName, currentSnap.SnapID, currentSnap.Revision.N) + return ¤tSnap, nil +} + +// verifySnapIntegrity verifies the integrity of a snap file +func verifySnapIntegrity(filePath, expectedChecksum string) bool { + checksumMatches, err := verifyChecksum(filePath, expectedChecksum) + if err != nil { + verboseLog("Checksum verification failed for %s: %v", filePath, err) + return false + } + return checksumMatches +} diff --git a/snapd-seed-glue/validation.go b/snapd-seed-glue/validation.go new file mode 100644 index 0000000..118205d --- /dev/null +++ b/snapd-seed-glue/validation.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// validateSeed validates the seed using snap debug +func validateSeed(seedYaml string) error { + cmd := exec.Command("snap", "debug", "validate-seed", seedYaml) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("validation failed with output: %s, error: %v", string(output), err) + } + verboseLog("Seed validation successful: %s", string(output)) + return nil +} + +// ensureAssertions ensures that essential assertions are present +func ensureAssertions(assertionsDir string) { + model := "generic-classic" + brand := "generic" + series := "16" // Hardcoded series as snap.Info does not have a Series field + + modelAssertionPath := filepath.Join(assertionsDir, "model") + accountKeyAssertionPath := filepath.Join(assertionsDir, "account-key") + accountAssertionPath := filepath.Join(assertionsDir, "account") + + // Check and generate model assertion + if _, err := os.Stat(modelAssertionPath); os.IsNotExist(err) { + output, err := exec.Command("snap", "known", "--remote", "model", "series="+series, "model="+model, "brand-id="+brand).CombinedOutput() + if err != nil { + log.Fatalf("Failed to fetch model assertion: %v, Output: %s", err, string(output)) + } + if err := ioutil.WriteFile(modelAssertionPath, output, 0644); err != nil { + log.Fatalf("Failed to write model assertion: %v", err) + } + verboseLog("Fetched and saved model assertion to %s", modelAssertionPath) + } + + // Generate account-key assertion if not exists + if _, err := os.Stat(accountKeyAssertionPath); os.IsNotExist(err) { + signKeySha3 := grepPattern(modelAssertionPath, "sign-key-sha3-384: ") + if signKeySha3 == "" { + log.Fatalf("Failed to extract sign-key-sha3-384 from model assertion.") + } + decodedSignKey, err := base64.StdEncoding.DecodeString(signKeySha3) + if err != nil { + log.Fatalf("Failed to decode sign-key-sha3-384: %v", err) + } + encodedSignKey := base64.StdEncoding.EncodeToString(decodedSignKey) + output, err := exec.Command("snap", "known", "--remote", "account-key", "public-key-sha3-384="+encodedSignKey).CombinedOutput() + if err != nil { + log.Fatalf("Failed to fetch account-key assertion: %v, Output: %s", err, string(output)) + } + if err := ioutil.WriteFile(accountKeyAssertionPath, output, 0644); err != nil { + log.Fatalf("Failed to write account-key assertion: %v", err) + } + verboseLog("Fetched and saved account-key assertion to %s", accountKeyAssertionPath) + } + + // Generate account assertion if not exists + if _, err := os.Stat(accountAssertionPath); os.IsNotExist(err) { + accountId := grepPattern(accountKeyAssertionPath, "account-id: ") + if accountId == "" { + log.Fatalf("Failed to extract account-id from account-key assertion.") + } + output, err := exec.Command("snap", "known", "--remote", "account", "account-id="+accountId).CombinedOutput() + if err != nil { + log.Fatalf("Failed to fetch account assertion: %v, Output: %s", err, string(output)) + } + if err := ioutil.WriteFile(accountAssertionPath, output, 0644); err != nil { + log.Fatalf("Failed to write account assertion: %v", err) + } + verboseLog("Fetched and saved account assertion to %s", accountAssertionPath) + } +} + + +// grepPattern extracts a specific pattern from a file +func grepPattern(filePath, pattern string) string { + content, err := ioutil.ReadFile(filePath) + if err != nil { + log.Fatalf("Failed to read from file %s: %v", filePath, err) + } + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.Contains(line, pattern) { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + encodedValue := strings.TrimSpace(parts[1]) + // Check if the value is base64 encoded + if decodedBytes, err := base64.StdEncoding.DecodeString(encodedValue); err == nil { + return string(decodedBytes) + } + return encodedValue + } + } + } + log.Fatalf("Pattern %s not found in file %s", pattern, filePath) + return "" +}