remove vm session feature
Cuts the daemon-managed guest-session machinery (start/list/show/
logs/stop/kill/attach/send). The feature shipped aimed at agent-
orchestration workflows (programmatic stdin piping into a long-lived
guest process) that aren't driving any concrete user today, and the
~2.3K LOC of daemon surface area — attach bridge, FIFO keepalive,
controller registry, sessionstream framing, SQLite persistence — was
locking in an API we'd have to keep through v0.1.0.
Anything session-flavoured that people actually need today can be
done with `vm ssh + tmux` or `vm run -- cmd`.
Deleted:
- internal/cli/commands_vm_session.go
- internal/daemon/{guest_sessions,session_lifecycle,session_attach,session_stream,session_controller}.go
- internal/daemon/session/ (guest-session helpers package)
- internal/sessionstream/ (framing package)
- internal/daemon/guest_sessions_test.go
- internal/store/guest_session_test.go
- GuestSession* types from internal/{api,model}
- Store UpsertGuestSession/GetGuestSession/ListGuestSessionsByVM/DeleteGuestSession + scanner helpers
- guest.session.* RPC dispatch entries
- 5 CLI session tests, 2 completion tests, 2 printer tests
Extracted:
- ShellQuote + FormatStepError lifted to internal/daemon/workspace/util.go
(only non-session consumer); workspace package now self-contained
- internal/daemon/guest_ssh.go keeps guestSSHClient + dialGuest +
waitForGuestSSH — still used by workspace prepare/export
- internal/daemon/fake_firecracker_test.go preserves the test helper
that used to live in guest_sessions_test.go
Store schema: CREATE TABLE guest_sessions and its column migrations
removed. Existing dev DBs keep an orphan table (harmless, pre-v0.1.0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c42fcbe012
commit
2b6437d1b4
34 changed files with 194 additions and 4031 deletions
|
|
@ -1,214 +0,0 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"banger/internal/model"
|
||||
)
|
||||
|
||||
func sampleGuestSession(id, vmID, name string) model.GuestSession {
|
||||
now := fixedTime()
|
||||
exit := 7
|
||||
return model.GuestSession{
|
||||
ID: id,
|
||||
VMID: vmID,
|
||||
Name: name,
|
||||
Backend: "ssh",
|
||||
AttachBackend: "vsock",
|
||||
AttachMode: "rpc",
|
||||
Command: "pi",
|
||||
Args: []string{"--mode", "rpc"},
|
||||
CWD: "/root/repo",
|
||||
Env: map[string]string{"FOO": "bar"},
|
||||
StdinMode: model.GuestSessionStdinMode("pipe"),
|
||||
Status: model.GuestSessionStatus("exited"),
|
||||
ExitCode: &exit,
|
||||
GuestPID: 1234,
|
||||
GuestStateDir: "/tmp/guest-" + id,
|
||||
StdoutLogPath: "/tmp/" + id + ".stdout",
|
||||
StderrLogPath: "/tmp/" + id + ".stderr",
|
||||
Tags: map[string]string{"role": "planner"},
|
||||
LastError: "",
|
||||
Attachable: true,
|
||||
Reattachable: true,
|
||||
LaunchStage: "started",
|
||||
LaunchMessage: "ok",
|
||||
LaunchRawLog: "boot log...",
|
||||
CreatedAt: now,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
EndedAt: now.Add(time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
// openTestStoreWithVMs opens a fresh store seeded with the given VM IDs so
|
||||
// guest_sessions FK constraints are satisfied. Each VM gets a minimal
|
||||
// image it references.
|
||||
func openTestStoreWithVMs(t *testing.T, vmIDs ...string) *Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
store := openTestStore(t)
|
||||
|
||||
image := sampleImage("stub-image")
|
||||
if err := store.UpsertImage(ctx, image); err != nil {
|
||||
t.Fatalf("UpsertImage: %v", err)
|
||||
}
|
||||
for i, id := range vmIDs {
|
||||
vm := sampleVM(id, image.ID, fmt.Sprintf("172.16.0.%d", i+2))
|
||||
vm.ID = id
|
||||
if err := store.UpsertVM(ctx, vm); err != nil {
|
||||
t.Fatalf("UpsertVM(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func TestGuestSessionUpsertAndGetByID(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := openTestStoreWithVMs(t, "vm-1")
|
||||
|
||||
session := sampleGuestSession("sess-1", "vm-1", "planner")
|
||||
if err := store.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetGuestSessionByID(ctx, "sess-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetGuestSessionByID: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, session) {
|
||||
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", got, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestSessionUpsertIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := openTestStoreWithVMs(t, "vm-1")
|
||||
|
||||
session := sampleGuestSession("sess-1", "vm-1", "planner")
|
||||
if err := store.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession (first): %v", err)
|
||||
}
|
||||
|
||||
// Mutate + re-upsert → existing row updated.
|
||||
session.Command = "pi --other"
|
||||
session.Status = model.GuestSessionStatus("running")
|
||||
session.ExitCode = nil
|
||||
if err := store.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession (second): %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetGuestSessionByID(ctx, "sess-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetGuestSessionByID: %v", err)
|
||||
}
|
||||
if got.Command != "pi --other" {
|
||||
t.Errorf("command = %q, want 'pi --other'", got.Command)
|
||||
}
|
||||
if got.Status != model.GuestSessionStatus("running") {
|
||||
t.Errorf("status = %q, want running", got.Status)
|
||||
}
|
||||
if got.ExitCode != nil {
|
||||
t.Errorf("ExitCode = %v, want nil after clearing", got.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGuestSessionByIDOrName(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := openTestStoreWithVMs(t, "vm-1")
|
||||
|
||||
session := sampleGuestSession("sess-1", "vm-1", "planner")
|
||||
if err := store.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession: %v", err)
|
||||
}
|
||||
|
||||
byID, err := store.GetGuestSession(ctx, "vm-1", "sess-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetGuestSession by ID: %v", err)
|
||||
}
|
||||
if byID.ID != "sess-1" {
|
||||
t.Errorf("by-ID: got %q, want sess-1", byID.ID)
|
||||
}
|
||||
|
||||
byName, err := store.GetGuestSession(ctx, "vm-1", "planner")
|
||||
if err != nil {
|
||||
t.Fatalf("GetGuestSession by name: %v", err)
|
||||
}
|
||||
if byName.Name != "planner" {
|
||||
t.Errorf("by-name: got %q, want planner", byName.Name)
|
||||
}
|
||||
|
||||
// Scoped to the VM.
|
||||
if _, err := store.GetGuestSession(ctx, "vm-unknown", "sess-1"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("wrong-vm lookup = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGuestSessionsByVMOrdersByCreatedAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := openTestStoreWithVMs(t, "vm-1", "vm-2")
|
||||
|
||||
base := fixedTime()
|
||||
first := sampleGuestSession("sess-early", "vm-1", "first")
|
||||
first.CreatedAt = base
|
||||
second := sampleGuestSession("sess-late", "vm-1", "second")
|
||||
second.CreatedAt = base.Add(time.Hour)
|
||||
other := sampleGuestSession("sess-other", "vm-2", "other")
|
||||
|
||||
for _, s := range []model.GuestSession{second, first, other} {
|
||||
if err := store.UpsertGuestSession(ctx, s); err != nil {
|
||||
t.Fatalf("UpsertGuestSession: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
sessions, err := store.ListGuestSessionsByVM(ctx, "vm-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListGuestSessionsByVM: %v", err)
|
||||
}
|
||||
if len(sessions) != 2 {
|
||||
t.Fatalf("len = %d, want 2 (vm-1 only)", len(sessions))
|
||||
}
|
||||
if sessions[0].ID != "sess-early" || sessions[1].ID != "sess-late" {
|
||||
t.Fatalf("order: got %q, %q; want sess-early, sess-late", sessions[0].ID, sessions[1].ID)
|
||||
}
|
||||
|
||||
empty, err := store.ListGuestSessionsByVM(ctx, "vm-unknown")
|
||||
if err != nil {
|
||||
t.Fatalf("ListGuestSessionsByVM (unknown vm): %v", err)
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("unknown vm sessions = %+v, want empty", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGuestSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := openTestStoreWithVMs(t, "vm-1")
|
||||
|
||||
session := sampleGuestSession("sess-1", "vm-1", "planner")
|
||||
if err := store.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession: %v", err)
|
||||
}
|
||||
if err := store.DeleteGuestSession(ctx, "sess-1"); err != nil {
|
||||
t.Fatalf("DeleteGuestSession: %v", err)
|
||||
}
|
||||
if _, err := store.GetGuestSessionByID(ctx, "sess-1"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("after delete err = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
|
||||
// Deleting something that doesn't exist is a no-op (matches SQL DELETE semantics).
|
||||
if err := store.DeleteGuestSession(ctx, "sess-nope"); err != nil {
|
||||
t.Fatalf("DeleteGuestSession on missing row: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -99,32 +99,6 @@ func (s *Store) migrate() error {
|
|||
stats_json TEXT NOT NULL DEFAULT '{}',
|
||||
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS guest_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
vm_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
args_json TEXT NOT NULL DEFAULT '[]',
|
||||
cwd TEXT,
|
||||
env_json TEXT NOT NULL DEFAULT '{}',
|
||||
stdin_mode TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
exit_code INTEGER,
|
||||
guest_pid INTEGER NOT NULL DEFAULT 0,
|
||||
guest_state_dir TEXT,
|
||||
stdout_log_path TEXT,
|
||||
stderr_log_path TEXT,
|
||||
tags_json TEXT NOT NULL DEFAULT '{}',
|
||||
last_error TEXT,
|
||||
attachable INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
UNIQUE(vm_id, name),
|
||||
FOREIGN KEY(vm_id) REFERENCES vms(id) ON DELETE CASCADE
|
||||
);`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
|
|
@ -137,18 +111,6 @@ func (s *Store) migrate() error {
|
|||
if err := ensureColumnExists(s.db, "images", "seeded_ssh_public_key_fingerprint", "TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, spec := range []struct{ table, column, typ string }{
|
||||
{"guest_sessions", "attach_backend", "TEXT"},
|
||||
{"guest_sessions", "attach_mode", "TEXT"},
|
||||
{"guest_sessions", "reattachable", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"guest_sessions", "launch_stage", "TEXT"},
|
||||
{"guest_sessions", "launch_message", "TEXT"},
|
||||
{"guest_sessions", "launch_raw_log", "TEXT"},
|
||||
} {
|
||||
if err := ensureColumnExists(s.db, spec.table, spec.column, spec.typ); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -336,122 +298,6 @@ func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.
|
|||
return vms, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertGuestSession(ctx context.Context, session model.GuestSession) error {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
argsJSON, err := json.Marshal(session.Args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
envJSON, err := json.Marshal(session.Env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tagsJSON, err := json.Marshal(session.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
const query = `
|
||||
INSERT INTO guest_sessions (
|
||||
id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
|
||||
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
|
||||
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
|
||||
created_at, started_at, updated_at, ended_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
vm_id=excluded.vm_id,
|
||||
name=excluded.name,
|
||||
backend=excluded.backend,
|
||||
attach_backend=excluded.attach_backend,
|
||||
attach_mode=excluded.attach_mode,
|
||||
command=excluded.command,
|
||||
args_json=excluded.args_json,
|
||||
cwd=excluded.cwd,
|
||||
env_json=excluded.env_json,
|
||||
stdin_mode=excluded.stdin_mode,
|
||||
status=excluded.status,
|
||||
exit_code=excluded.exit_code,
|
||||
guest_pid=excluded.guest_pid,
|
||||
guest_state_dir=excluded.guest_state_dir,
|
||||
stdout_log_path=excluded.stdout_log_path,
|
||||
stderr_log_path=excluded.stderr_log_path,
|
||||
tags_json=excluded.tags_json,
|
||||
last_error=excluded.last_error,
|
||||
attachable=excluded.attachable,
|
||||
reattachable=excluded.reattachable,
|
||||
launch_stage=excluded.launch_stage,
|
||||
launch_message=excluded.launch_message,
|
||||
launch_raw_log=excluded.launch_raw_log,
|
||||
started_at=excluded.started_at,
|
||||
updated_at=excluded.updated_at,
|
||||
ended_at=excluded.ended_at`
|
||||
_, err = s.db.ExecContext(ctx, query,
|
||||
session.ID,
|
||||
session.VMID,
|
||||
session.Name,
|
||||
session.Backend,
|
||||
session.AttachBackend,
|
||||
session.AttachMode,
|
||||
session.Command,
|
||||
string(argsJSON),
|
||||
session.CWD,
|
||||
string(envJSON),
|
||||
string(session.StdinMode),
|
||||
string(session.Status),
|
||||
nullableInt(session.ExitCode),
|
||||
session.GuestPID,
|
||||
session.GuestStateDir,
|
||||
session.StdoutLogPath,
|
||||
session.StderrLogPath,
|
||||
string(tagsJSON),
|
||||
session.LastError,
|
||||
boolToInt(session.Attachable),
|
||||
boolToInt(session.Reattachable),
|
||||
session.LaunchStage,
|
||||
session.LaunchMessage,
|
||||
session.LaunchRawLog,
|
||||
session.CreatedAt.Format(time.RFC3339),
|
||||
nullableTimeString(session.StartedAt),
|
||||
session.UpdatedAt.Format(time.RFC3339),
|
||||
nullableTimeString(session.EndedAt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetGuestSessionByID(ctx context.Context, id string) (model.GuestSession, error) {
|
||||
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE id = ?", id)
|
||||
return scanGuestSessionRow(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetGuestSession(ctx context.Context, vmID, idOrName string) (model.GuestSession, error) {
|
||||
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? AND (id = ? OR name = ?)", vmID, idOrName, idOrName)
|
||||
return scanGuestSessionRow(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListGuestSessionsByVM(ctx context.Context, vmID string) ([]model.GuestSession, error) {
|
||||
rows, err := s.db.QueryContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? ORDER BY created_at ASC", vmID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var sessions []model.GuestSession
|
||||
for rows.Next() {
|
||||
session, err := scanGuestSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteGuestSession(ctx context.Context, id string) error {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
_, err := s.db.ExecContext(ctx, "DELETE FROM guest_sessions WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) NextGuestIP(ctx context.Context, bridgeIPPrefix string) (string, error) {
|
||||
used := map[string]struct{}{}
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
|
||||
|
|
@ -622,113 +468,6 @@ func boolToInt(value bool) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
const guestSessionSelectSQL = `
|
||||
SELECT id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
|
||||
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
|
||||
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
|
||||
created_at, started_at, updated_at, ended_at
|
||||
FROM guest_sessions`
|
||||
|
||||
func scanGuestSession(rows scanner) (model.GuestSession, error) {
|
||||
return scanGuestSessionRow(rows)
|
||||
}
|
||||
|
||||
func scanGuestSessionRow(row scanner) (model.GuestSession, error) {
|
||||
var session model.GuestSession
|
||||
var (
|
||||
argsJSON string
|
||||
envJSON string
|
||||
tagsJSON string
|
||||
stdinMode string
|
||||
status string
|
||||
exitCode sql.NullInt64
|
||||
startedAt sql.NullString
|
||||
endedAt sql.NullString
|
||||
attachable int
|
||||
reattachable int
|
||||
createdRaw string
|
||||
updatedRaw string
|
||||
)
|
||||
err := row.Scan(
|
||||
&session.ID,
|
||||
&session.VMID,
|
||||
&session.Name,
|
||||
&session.Backend,
|
||||
&session.AttachBackend,
|
||||
&session.AttachMode,
|
||||
&session.Command,
|
||||
&argsJSON,
|
||||
&session.CWD,
|
||||
&envJSON,
|
||||
&stdinMode,
|
||||
&status,
|
||||
&exitCode,
|
||||
&session.GuestPID,
|
||||
&session.GuestStateDir,
|
||||
&session.StdoutLogPath,
|
||||
&session.StderrLogPath,
|
||||
&tagsJSON,
|
||||
&session.LastError,
|
||||
&attachable,
|
||||
&reattachable,
|
||||
&session.LaunchStage,
|
||||
&session.LaunchMessage,
|
||||
&session.LaunchRawLog,
|
||||
&createdRaw,
|
||||
&startedAt,
|
||||
&updatedRaw,
|
||||
&endedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return session, err
|
||||
}
|
||||
session.StdinMode = model.GuestSessionStdinMode(stdinMode)
|
||||
session.Status = model.GuestSessionStatus(status)
|
||||
session.Attachable = attachable == 1
|
||||
session.Reattachable = reattachable == 1
|
||||
if argsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(argsJSON), &session.Args); err != nil {
|
||||
return session, err
|
||||
}
|
||||
}
|
||||
if envJSON != "" {
|
||||
if err := json.Unmarshal([]byte(envJSON), &session.Env); err != nil {
|
||||
return session, err
|
||||
}
|
||||
}
|
||||
if tagsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &session.Tags); err != nil {
|
||||
return session, err
|
||||
}
|
||||
}
|
||||
if exitCode.Valid {
|
||||
value := int(exitCode.Int64)
|
||||
session.ExitCode = &value
|
||||
}
|
||||
var parseErr error
|
||||
session.CreatedAt, parseErr = time.Parse(time.RFC3339, createdRaw)
|
||||
if parseErr != nil {
|
||||
return session, parseErr
|
||||
}
|
||||
session.UpdatedAt, parseErr = time.Parse(time.RFC3339, updatedRaw)
|
||||
if parseErr != nil {
|
||||
return session, parseErr
|
||||
}
|
||||
if startedAt.Valid && startedAt.String != "" {
|
||||
session.StartedAt, parseErr = time.Parse(time.RFC3339, startedAt.String)
|
||||
if parseErr != nil {
|
||||
return session, parseErr
|
||||
}
|
||||
}
|
||||
if endedAt.Valid && endedAt.String != "" {
|
||||
session.EndedAt, parseErr = time.Parse(time.RFC3339, endedAt.String)
|
||||
if parseErr != nil {
|
||||
return session, parseErr
|
||||
}
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func nullableTimeString(value time.Time) any {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue