Merge pull request #2816 from lightpanda-io/nikneym/check-update

initial implementation for `update` command
This commit is contained in:
Karl Seguin
2026-07-09 10:06:26 +08:00
committed by GitHub
6 changed files with 152 additions and 13 deletions

View File

@@ -262,7 +262,9 @@ const Commands = cli.Builder(.{
},
.shared_options = CommonOptions,
},
.{ .name = "version", .options = .{} },
.{ .name = "version", .options = .{
.{ .name = "check", .type = bool },
} },
});
pub const RunMode = Commands.Enum;
@@ -274,7 +276,11 @@ exec_name: []const u8,
http_headers: HttpHeaders,
fn modeNeedsHttp(mode: Mode) bool {
return mode != .help and mode != .version;
return switch (mode) {
.help => false,
.version => |opts| opts.check,
else => true,
};
}
pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config {
@@ -307,6 +313,8 @@ pub fn interactive(self: *const Config) bool {
pub fn tlsVerifyHost(self: *const Config) bool {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| !opts.insecure_disable_tls_host_verification,
// `version --check` talks to the release endpoint; always verify.
.version => true,
else => unreachable,
};
}
@@ -356,6 +364,7 @@ pub fn v8MaxHeapMb(self: *const Config) ?u32 {
pub fn httpProxy(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.http_proxy,
.version => null,
else => unreachable,
};
}
@@ -363,7 +372,7 @@ pub fn httpProxy(self: *const Config) ?[:0]const u8 {
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.proxy_bearer_token,
.help, .version => null,
else => null,
};
}
@@ -384,6 +393,7 @@ pub fn httpMaxHostOpen(self: *const Config) u8 {
pub fn httpConnectTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.http_connect_timeout orelse 0,
.version => 0,
else => unreachable,
};
}
@@ -391,6 +401,7 @@ pub fn httpConnectTimeout(self: *const Config) u31 {
pub fn httpTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.http_timeout orelse 5000,
.version => 5000,
else => unreachable,
};
}
@@ -465,14 +476,14 @@ pub fn logFilterScopes(self: *const Config) std.ArrayList(log.FilterRule) {
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent_suffix,
.help, .version => null,
else => null,
};
}
pub fn userAgent(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent,
.help, .version => null,
else => null,
};
}
@@ -520,7 +531,7 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
.keyid = opts.web_bot_auth_keyid orelse return null,
.domain = opts.web_bot_auth_domain orelse return null,
},
.help, .version => null,
else => null,
};
}

101
src/Updater.zig Normal file
View File

@@ -0,0 +1,101 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const json = std.json;
const SemanticVersion = std.SemanticVersion;
const Allocator = std.mem.Allocator;
const lp = @import("lightpanda");
const Network = @import("network/Network.zig");
const http = @import("network/http.zig");
const libcurl = @import("sys/libcurl.zig");
const crypto = @import("sys/libcrypto.zig");
const Config = @import("Config.zig");
const log = @import("log.zig");
/// Sole purpose of this client is to do updates; hence, its very minimal.
const Updater = @This();
x509_store: *crypto.X509_STORE,
config: *const Config,
/// Initializes the update client; meant to be used as singleton.
pub fn init(allocator: Allocator, config: *const Config) !Updater {
Network.globalInit(allocator);
errdefer Network.globalDeinit();
const x509_store = try Network.createX509Store(allocator);
return .{
.x509_store = x509_store,
.config = config,
};
}
pub fn deinit(self: *Updater) void {
Network.globalDeinit();
crypto.X509_STORE_free(self.x509_store);
}
/// Sends running Lightpanda version to remote to get update information.
/// Outputs directly to given `Writer`.
pub fn inform(self: *Updater, writer: *std.Io.Writer) !void {
const conn = try http.Connection.init(self.x509_store, self.config, null);
defer conn.deinit();
const url = std.fmt.comptimePrint("https://telemetry.lightpanda.io/v/{s}", .{lp.build_config.version});
// Prepare the request.
try conn.setURL(url);
try conn.setGetMode();
try conn.setFollowLocation(true);
// Wraps everything needed to receive bytes.
const ReceiverContext = struct {
writer: *std.Io.Writer,
err: std.Io.Writer.Error!void = {},
/// curl -> writer.
fn drain(
buffer: [*]const u8,
buf_count: usize,
buf_len: usize,
raw_ctx: *anyopaque,
) callconv(.c) usize {
const ctx: *@This() = @ptrCast(@alignCast(raw_ctx));
const chunk = buffer[0 .. buf_count * buf_len];
ctx.writer.writeAll(chunk) catch |err| {
ctx.err = err;
return 0;
};
return chunk.len;
}
};
// Set receiver context.
var ctx = ReceiverContext{ .writer = writer };
try libcurl.curl_easy_setopt(conn._easy, .write_data, &ctx);
try libcurl.curl_easy_setopt(conn._easy, .write_function, ReceiverContext.drain);
// Make a request.
const status_int = conn.perform() catch |err| {
ctx.err catch |ctx_err| return ctx_err;
return err;
};
_ = status_int;
return writer.flush();
}

View File

@@ -238,9 +238,15 @@
\\API key.
,
.version =
\\usage: {0s} version
\\usage: {0s} version [OPTIONS]
\\
\\Displays the version of {0s}.
\\
\\options:
\\ --check
\\ Checks whether a newer release of {0s} is available. When one is
\\ found, prints the running version, the latest release, and how to
\\ update.
,
.help =
\\usage: {0s} help

View File

@@ -56,6 +56,8 @@ pub const build_config = @import("build_config");
pub const crash_handler = @import("crash_handler.zig");
pub const core_dump = @import("core_dump.zig");
pub const Updater = @import("Updater.zig");
pub const FetchOpts = struct {
wait_ms: u32 = 5000,
wait_until: ?Config.WaitUntil = null,
@@ -223,6 +225,17 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, dump_opts: dump.Opts, frame:
}
}
pub fn checkVersion(allocator: std.mem.Allocator, config: *const Config) !void {
var client = try Updater.init(allocator, config);
defer client.deinit();
var stdout = std.fs.File.stdout();
var buf: [4096]u8 = undefined;
var writer = stdout.writer(&buf);
const w = &writer.interface;
try client.inform(w);
}
// Writes a single page's result object. Framing (the enclosing array and any
// separators / trailing newline) is the caller's responsibility.
fn writeJsonEnvelope(writer: *std.Io.Writer, frame: ?*Frame, dump_mode: ?Config.DumpFormat, content: []const u8) !void {

View File

@@ -71,9 +71,13 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
switch (args.mode) {
.help => |tag| return args.printUsageAndExit(tag, true),
.version => {
var stdout = std.fs.File.stdout().writer(&.{});
try stdout.interface.print("{s}\n", .{lp.build_config.version});
.version => |opts| {
if (opts.check) {
try lp.checkVersion(allocator, &args);
} else {
var stdout = std.fs.File.stdout().writer(&.{});
try stdout.interface.print("{s}\n", .{lp.build_config.version});
}
return std.process.cleanExit();
},
.agent => |opts| if (opts.list_models) {

View File

@@ -138,7 +138,9 @@ cdp_start: usize,
/// Optional IP filter for blocking requests to private/internal networks (--block-private-networks).
ip_filter: ?*IpFilter = null,
fn globalInit(allocator: Allocator) void {
/// Calling `init` also calls this function; only marked public for situations
/// networking is needed without `App`.
pub fn globalInit(allocator: Allocator) void {
// Only route curl's own allocations through our allocator in Debug, so the
// leak detector sees them. In Release it'd just wrap c_allocator (curl's
// default malloc anyway) at the cost of a per-allocation header.
@@ -152,7 +154,9 @@ fn globalInit(allocator: Allocator) void {
};
}
fn globalDeinit() void {
/// Calling `deinit` also calls this function; only marked public for situations
/// networking is needed without `App`.
pub fn globalDeinit() void {
libcurl.curl_global_cleanup();
}
@@ -720,7 +724,7 @@ const CreateX509StoreError = std.crypto.Certificate.Bundle.RescanError || error{
/// NEVER give full ownership of store to `SSL_CTX`, always rely on ref counting.
/// Allocations made through passed `allocator` are freed before this function returns.
fn createX509Store(allocator: Allocator) CreateX509StoreError!*crypto.X509_STORE {
pub fn createX509Store(allocator: Allocator) CreateX509StoreError!*crypto.X509_STORE {
const store = crypto.X509_STORE_new() orelse return error.FailedToCreateX509Store;
errdefer crypto.X509_STORE_free(store);