diff --git a/doc/.custom_wordlist.txt b/doc/.custom_wordlist.txt index da97d3eaadf0..efee87850c29 100644 --- a/doc/.custom_wordlist.txt +++ b/doc/.custom_wordlist.txt @@ -141,6 +141,7 @@ MiB Mibit MicroCeph MicroCloud +MicroOVN MII MinIO MITM diff --git a/doc/howto/network_ovn_setup.md b/doc/howto/network_ovn_setup.md index 3598a980a70d..2a406217e2a6 100644 --- a/doc/howto/network_ovn_setup.md +++ b/doc/howto/network_ovn_setup.md @@ -162,6 +162,10 @@ See the linked YouTube video for the complete tutorial using four machines. lxc config set network.ovn.northbound_connection + ```{note} + If you are using a MicroOVN deployment, pass the value of the MicroOVN node IP address you want to target. Prefix the IP address with `ssl:`, and suffix it with the `:6641` port number that corresponds to the OVN central service within MicroOVN. + ``` + 1. Finally, create the actual OVN network (on the first machine): lxc network create my-ovn --type=ovn diff --git a/lxd/network/acl/acl_interface.go b/lxd/network/acl/acl_interface.go index 13d329318b4f..33d45c5baa2e 100644 --- a/lxd/network/acl/acl_interface.go +++ b/lxd/network/acl/acl_interface.go @@ -1,6 +1,8 @@ package acl import ( + "context" + "github.com/canonical/lxd/lxd/cluster/request" "github.com/canonical/lxd/lxd/state" "github.com/canonical/lxd/shared/api" @@ -19,7 +21,7 @@ type NetworkACL interface { UsedBy() ([]string, error) // GetLog. - GetLog(clientType request.ClientType) (string, error) + GetLog(ctx context.Context, clientType request.ClientType) (string, error) // Internal validation. validateName(name string) error diff --git a/lxd/network/acl/acl_ovn.go b/lxd/network/acl/acl_ovn.go index 0a3b19b22f7e..f61f2d183c13 100644 --- a/lxd/network/acl/acl_ovn.go +++ b/lxd/network/acl/acl_ovn.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "fmt" + "io" "net" + "strconv" "strings" "time" @@ -1042,7 +1044,23 @@ type ovnLogEntry struct { } // ovnParseLogEntry takes a log line and expected ACL prefix and returns a re-formated log entry if matching. -func ovnParseLogEntry(input string, prefix string) string { +// The 'timestamp' string is in microseconds format. If empty, the timestamp is extracted from the log entry. +func ovnParseLogEntry(input string, timestamp string, prefix string) string { + parseLogTimeFromFields := func(fields []string) (time.Time, error) { + return time.Parse(time.RFC3339, fields[0]) + } + + parseLogTimeFromTimestamp := func(timestamp string) (time.Time, error) { + tsInt, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("Failed to parse timestamp: %w", err) + } + + // The provided timestamp is in microseconds and need to be converted to nanoseconds. + tsNs := tsInt * 1000 + return time.Unix(0, tsNs).UTC(), nil + } + fields := strings.Split(input, "|") // Skip unknown formatting. @@ -1071,10 +1089,18 @@ func ovnParseLogEntry(input string, prefix string) string { return "" } - // Parse the timestamp. - logTime, err := time.Parse(time.RFC3339, fields[0]) - if err != nil { - return "" + var logTime time.Time + var err error + if timestamp == "" { + logTime, err = parseLogTimeFromFields(fields) + if err != nil { + return "" + } + } else { + logTime, err = parseLogTimeFromTimestamp(timestamp) + if err != nil { + return "" + } } // Get the protocol. @@ -1131,3 +1157,56 @@ func ovnParseLogEntry(input string, prefix string) string { return string(out) } + +// ovnParseLogEntriesFromJournald reads the OVN log entries from the systemd journal and returns them as a list of string entries. +func ovnParseLogEntriesFromJournald(ctx context.Context, systemdUnitName string, prefix string) ([]string, error) { + var logEntries []string + cmd := []string{ + "/usr/bin/journalctl", + "--unit", systemdUnitName, + "--directory", shared.HostPath("/var/log/journal"), + "--no-pager", + "--boot", "0", + "--case-sensitive", + "--grep", prefix, + "--output-fields", "MESSAGE", + "-n", "1000", + "-o", "json", + } + + stdout := strings.Builder{} + err := shared.RunCommandWithFds(ctx, nil, &stdout, cmd[0], cmd[1:]...) + if err != nil { + return nil, fmt.Errorf("Failed to run journalctl to fetch OVN ACL logs: %w", err) + } + + decoder := json.NewDecoder(strings.NewReader(stdout.String())) + for { + var sdLogEntry map[string]any + err = decoder.Decode(&sdLogEntry) + if err == io.EOF { + break + } else if err != nil { + return nil, fmt.Errorf("Failed to parse log entry: %w", err) + } + + message, ok := sdLogEntry["MESSAGE"].(string) + if !ok { + continue + } + + timestamp, ok := sdLogEntry["__REALTIME_TIMESTAMP"].(string) + if !ok { + continue + } + + logEntry := ovnParseLogEntry(message, timestamp, prefix) + if logEntry == "" { + continue + } + + logEntries = append(logEntries, logEntry) + } + + return logEntries, nil +} diff --git a/lxd/network/acl/driver_common.go b/lxd/network/acl/driver_common.go index 9d863a3c29a0..f07bc0a17146 100644 --- a/lxd/network/acl/driver_common.go +++ b/lxd/network/acl/driver_common.go @@ -749,35 +749,52 @@ func (d *common) Delete() error { } // GetLog gets the ACL log. -func (d *common) GetLog(clientType request.ClientType) (string, error) { +func (d *common) GetLog(ctx context.Context, clientType request.ClientType) (string, error) { // ACLs aren't specific to a particular network type but the log only works with OVN. - logPath := shared.HostPath("/var/log/ovn/ovn-controller.log") - if !shared.PathExists(logPath) { - return "", fmt.Errorf("Only OVN log entries may be retrieved at this time") - } + var logEntries []string + var err error + + // First, check if we can resolve the /run/openvswitch symlink to determine if OVN is in use. + // This is used in case of a MicroOVN deployment is interfaced with LXD. + targetPath, err := os.Readlink("/run/openvswitch") + if err == nil && strings.HasSuffix(targetPath, "/microovn/chassis/switch") { + prefix := fmt.Sprintf("lxd_acl%d-", d.id) + logEntries, err = ovnParseLogEntriesFromJournald(ctx, "snap.microovn.chassis.service", prefix) + if err != nil { + return "", fmt.Errorf("Failed to get OVN log entries from syslog: %v\n", err) + } + } else { + // Else, if the /run/openvswitch symlink doesn't exist (which might be the case for a non-snap installation of LXD), + // then try to read the OVN controller log file directly. + logEntries = []string{} + prefix := fmt.Sprintf("lxd_acl%d-", d.id) + logPath := shared.HostPath("/var/log/ovn/ovn-controller.log") + if !shared.PathExists(logPath) { + return "", fmt.Errorf("Only OVN log entries may be retrieved at this time") + } - // Open the log file. - logFile, err := os.Open(logPath) - if err != nil { - return "", fmt.Errorf("Couldn't open OVN log file: %w", err) - } + // Open the log file. + logFile, err := os.Open(logPath) + if err != nil { + return "", fmt.Errorf("Failed to open OVN log file: %w", err) + } - defer func() { _ = logFile.Close() }() + defer func() { _ = logFile.Close() }() - logEntries := []string{} - scanner := bufio.NewScanner(logFile) - for scanner.Scan() { - logEntry := ovnParseLogEntry(scanner.Text(), fmt.Sprintf("lxd_acl%d-", d.id)) - if logEntry == "" { - continue - } + scanner := bufio.NewScanner(logFile) + for scanner.Scan() { + logEntry := ovnParseLogEntry(scanner.Text(), "", prefix) + if logEntry == "" { + continue + } - logEntries = append(logEntries, logEntry) - } + logEntries = append(logEntries, logEntry) + } - err = scanner.Err() - if err != nil { - return "", fmt.Errorf("Failed to read OVN log file: %w", err) + err = scanner.Err() + if err != nil { + return "", fmt.Errorf("Failed to read OVN log file: %w", err) + } } // Aggregates the entries from the rest of the cluster. diff --git a/lxd/network_acls.go b/lxd/network_acls.go index ea446ba2f7cf..d56cabb4a9ba 100644 --- a/lxd/network_acls.go +++ b/lxd/network_acls.go @@ -637,7 +637,7 @@ func networkACLLogGet(d *Daemon, r *http.Request) response.Response { } clientType := clusterRequest.UserAgentClientType(r.Header.Get("User-Agent")) - log, err := netACL.GetLog(clientType) + log, err := netACL.GetLog(r.Context(), clientType) if err != nil { return response.SmartError(err) }