Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/workshopctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ var clientConfig = client.Config{
}

func main() {
// signal-ready is a guest-local subcommand: it must run as root (the
// devlxd socket rejects non-root callers) and must not be forwarded to
// the workshop daemon. Handle it before dropping privileges below.
if len(os.Args) > 1 && os.Args[1] == signalReadyCommand {
if err := signalReady(); err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
return
}

// Set the user and group IDs to the workshop user
uid := uint32(1000) // Change this to the workshop UID

Expand Down
200 changes: 200 additions & 0 deletions cmd/workshopctl/ready.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
* Copyright (C) 2026 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* 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.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package main

import (
"context"
"fmt"
"os"
"strconv"
"time"

lxd "github.com/canonical/lxd/client"
"github.com/canonical/lxd/shared/api"
"github.com/coreos/go-systemd/daemon"
"github.com/godbus/dbus/v5"
)

// signalReadyCommand is a guest-local workshopctl subcommand. Unlike every
// other workshopctl invocation, it is not forwarded to the workshop daemon:
// it runs entirely inside the workshop instance. It waits for the instance's
// system manager to finish booting and then reports readiness to LXD over the
// devlxd socket, transitioning the instance from "Started" to "Ready".
//
// It replaces the previous start_command.sh, which busy-looped on
// `systemctl is-system-running` and was executed from the host. Because the
// instance signals itself over devlxd, the host no longer needs to exec into
// the instance to determine readiness.
const signalReadyCommand = "signal-ready"

// devLXDSocket is the path to the devlxd socket inside the instance.
const devLXDSocket = "/dev/lxd/sock"

// systemdPrivateSocket is systemd's private D-Bus socket. We talk to systemd
// directly over it (as systemctl does) rather than via the system bus, so that
// readiness signalling does not depend on the system dbus-daemon being up or on
// its AppArmor mediation being able to query our (confined) policy.
const systemdPrivateSocket = "unix:path=/run/systemd/private"

// signalReadyTimeout bounds how long we wait for the system to finish booting
// before giving up. It mirrors the old start command timeout.
const signalReadyTimeout = time.Minute

// signalReady waits for the system manager to finish booting and then reports
// readiness to LXD over the devlxd socket.
//
// This subcommand runs as a Type=notify systemd service. It connects to systemd
// and subscribes to the boot-completion signal, then notifies its own service
// manager that it has started (READY=1). That notification completes the
// service's start job so it leaves the boot job queue early; otherwise the queue
// could never drain, since the queue must be empty for the boot to be reported
// as finished and we would be waiting for a queue that still contains us. Only
// after this does it wait for the (independent) system-wide boot completion and
// report readiness to the host.
func signalReady() error {
ctx, cancel := context.WithTimeout(context.Background(), signalReadyTimeout)
defer cancel()

conn, err := connectSystemd()
if err != nil {
return fmt.Errorf("cannot connect to systemd: %w", err)
}
defer conn.Close()

signals := subscribeStartupFinished(conn)

// Read the current state before notifying so that, combined with the
// subscription above, we cannot miss a boot completion that happens while
// we hand off readiness of our own service.
state, err := systemState(conn)
if err != nil {
return fmt.Errorf("cannot read system state: %w", err)
}

// Tell our own service manager we have started. This is independent of the
// system-wide boot completion we wait for below.
if _, err := daemon.SdNotify(false, daemon.SdNotifyReady); err != nil {
return fmt.Errorf("cannot notify readiness to systemd: %w", err)
}

if err := waitForBoot(ctx, conn, signals, state); err != nil {
return fmt.Errorf("cannot wait for system to finish booting: %w", err)
}

if err := reportReady(); err != nil {
return fmt.Errorf("cannot report readiness to LXD: %w", err)
}

return nil
}

// bootFinished reports whether the given systemd SystemState indicates that
// booting has completed. This mirrors `systemctl is-system-running`, which
// considers the system still booting only while "initializing" or "starting";
// every other state (running, degraded, maintenance, stopping) means the boot
// transaction has settled.
func bootFinished(state string) bool {
switch state {
case "initializing", "starting", "":
return false
default:
return true
}
}

// connectSystemd opens a direct connection to systemd's private socket, using
// EXTERNAL authentication and deliberately skipping the org.freedesktop.DBus
// Hello handshake (the private socket is a peer connection, not a message bus).
func connectSystemd() (*dbus.Conn, error) {
conn, err := dbus.Dial(systemdPrivateSocket)
if err != nil {
return nil, err
}
if err := conn.Auth([]dbus.Auth{dbus.AuthExternal(strconv.Itoa(os.Getuid()))}); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}

// subscribeStartupFinished subscribes to the manager's StartupFinished signal
// and returns a channel of received signals. Over the systemd private socket
// there is no message-bus object, so AddMatch errors out; systemd nevertheless
// delivers its manager signals to the peer, so this is best-effort (matching
// go-systemd's own behaviour).
func subscribeStartupFinished(conn *dbus.Conn) chan *dbus.Signal {
conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0,
"type='signal',interface='org.freedesktop.systemd1.Manager',member='StartupFinished'")

signals := make(chan *dbus.Signal, 1)
conn.Signal(signals)
return signals
}

// waitForBoot blocks until the system manager reports that it has finished
// booting. It mimics `systemctl is-system-running --wait`: the caller must have
// already subscribed to StartupFinished and read the initial state, so that a
// boot completion happening between subscribe and read cannot be missed.
func waitForBoot(ctx context.Context, conn *dbus.Conn, signals chan *dbus.Signal, state string) error {
if bootFinished(state) {
return nil
}

for {
select {
case <-ctx.Done():
return ctx.Err()
case <-signals:
// A StartupFinished signal arrived; confirm via the
// property in case of spurious wakeups.
state, err := systemState(conn)
if err != nil {
return err
}
if bootFinished(state) {
return nil
}
}
}
}

// systemState reads the org.freedesktop.systemd1.Manager SystemState property.
func systemState(conn *dbus.Conn) (string, error) {
manager := conn.Object("org.freedesktop.systemd1", "/org/freedesktop/systemd1")
v, err := manager.GetProperty("org.freedesktop.systemd1.Manager.SystemState")
if err != nil {
return "", err
}
state, ok := v.Value().(string)
if !ok {
return "", fmt.Errorf("unexpected SystemState type %T", v.Value())
}
return state, nil
}

// reportReady connects to the devlxd socket and transitions the instance to the
// Ready state.
func reportReady() error {
server, err := lxd.ConnectDevLXD(devLXDSocket, nil)
if err != nil {
return err
}
defer server.Disconnect()

return server.UpdateState(api.DevLXDPut{State: api.Ready.String()})
}
4 changes: 2 additions & 2 deletions internal/daemon/api_connections_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func (s *apiSuite) workshopFile(ws string, sdks []*sdk.Info) *workshop.File {
func (s *apiSuite) mockInstalledSDK(c *check.C, yaml string, w string) *workshop.Workshop {
info := sdk.MockInfo(c, yaml, s.project.ProjectId, w)
wf := s.workshopFile(w, []*sdk.Info{info})
snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123")
snapshot := workshop.BaseOnly(workshop.ConfinementContainer, sdk.R(1), wf.Base, "fakeimage123")
c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil)

wp, err := s.b.Workshop(s.ctx, w)
Expand Down Expand Up @@ -128,7 +128,7 @@ func (s *apiSuite) mockInstalledSDKBoundPlug(c *check.C, yaml string, w string,
Name: to}
c.Assert(s.d.overlord.InterfaceManager().Repository().AddSdk(info), check.IsNil)
wf := s.workshopFile(w, []*sdk.Info{info})
snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123")
snapshot := workshop.BaseOnly(workshop.ConfinementContainer, sdk.R(1), wf.Base, "fakeimage123")
c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil)
wp, err := s.b.Workshop(s.ctx, w)
c.Check(err, check.IsNil)
Expand Down
2 changes: 1 addition & 1 deletion internal/daemon/api_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (s *apiSuite) setupExec(c *check.C) *Command {
s.createWFile(c, "ws", wsYaml)

wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04", Actions: map[string]workshop.Action{"lint": "\n\n\ngolangci-lint run\n"}}
snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123")
snapshot := workshop.BaseOnly(workshop.ConfinementContainer, sdk.R(1), wf.Base, "fakeimage123")

err := s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot)
c.Assert(err, check.IsNil)
Expand Down
8 changes: 4 additions & 4 deletions internal/daemon/api_sdks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,11 +439,11 @@ func (s *apiSuite) TestSdkInfoGetOk(c *check.C) {
s.createWFile(c, "lerobot", "name: lerobot\nbase: ubuntu@20.04\n")

wf := &workshop.File{Name: "nav2", Base: "ubuntu@20.04"}
snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123")
snapshot := workshop.BaseOnly(workshop.ConfinementContainer, sdk.R(1), wf.Base, "fakeimage123")
c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil)

wf = &workshop.File{Name: "lerobot", Base: "ubuntu@20.04"}
snapshot = workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123")
snapshot = workshop.BaseOnly(workshop.ConfinementContainer, sdk.R(1), wf.Base, "fakeimage123")
c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil)

// Add SDK setups with channels so the endpoint can report channels.
Expand Down Expand Up @@ -667,7 +667,7 @@ func (s *apiSuite) TestSdkInfoLocalOnly(c *check.C) {

s.createWFile(c, "nav2", "name: nav2\nbase: ubuntu@20.04\n")
wf := &workshop.File{Name: "nav2", Base: "ubuntu@20.04"}
snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123")
snapshot := workshop.BaseOnly(workshop.ConfinementContainer, sdk.R(1), wf.Base, "fakeimage123")
c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil)

// Add SDK setup with channels so the endpoint can report channels.
Expand Down Expand Up @@ -798,7 +798,7 @@ func (s *apiSuite) TestSdkInfoGetInvalidLocalMetadata(c *check.C) {

s.createWFile(c, "ws", "name: ws\nbase: ubuntu@20.04\n")
wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04"}
snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123")
snapshot := workshop.BaseOnly(workshop.ConfinementContainer, sdk.R(1), wf.Base, "fakeimage123")
c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil)

meta := sdk.Meta{
Expand Down
7 changes: 5 additions & 2 deletions internal/daemon/api_workshops.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,14 +613,16 @@ func v1PostProjectWorkshop(c *Command, r *http.Request, _ *userState) Response {
st.Lock()
defer st.Unlock()

wsmgr := c.d.overlord.WorkshopManager()

var change *state.Change
var current, latest, stashed []workshopstate.Manifest
var tasksets []*state.TaskSet

if mode.Resume() {
change, err = conflict.ResumeAfterWait(st, reqData.Names[0], projectId, mode, reqData.Action)
format := wsmgr.FormatRevision()
change, err = conflict.ResumeAfterWait(st, format, reqData.Names[0], projectId, mode, reqData.Action)
} else {
wsmgr := c.d.overlord.WorkshopManager()
switch reqData.Action {
case "launch":
change = newWorkshopChange(st, "launch", user, projectId, reqData.Action, reqData.Names)
Expand Down Expand Up @@ -671,6 +673,7 @@ func v1PostProjectWorkshop(c *Command, r *http.Request, _ *userState) Response {
}
for age, manifests := range manifestsByAge {
for _, m := range manifests {
change.Set(handlersetup.WorkshopConfinementKey(m.File.Name, age), m.File.Confinement)
change.Set(handlersetup.WorkshopFormatKey(m.File.Name, age), m.Format)
change.Set(handlersetup.WorkshopBaseKey(m.File.Name, age), m.Image)
change.Set(handlersetup.WorkshopSdksKey(m.File.Name, age), m.Sdks)
Expand Down
4 changes: 2 additions & 2 deletions internal/daemon/api_workshops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3508,7 +3508,7 @@ func (s *apiSuite) TestRefreshBaseUpdate(c *check.C) {
defer s.store.SetDownloadCallback(storeDownload(c))()

oldGetBase := s.b.GetBaseCallback
s.b.GetBaseCallback = func(ctx context.Context, base string) (workshop.BaseImage, error) {
s.b.GetBaseCallback = func(ctx context.Context, confinement workshop.Confinement, base string) (workshop.BaseImage, error) {
return workshop.BaseImage{Name: base, Fingerprint: "oldimage123"}, nil
}
defer func() { s.b.GetBaseCallback = oldGetBase }()
Expand Down Expand Up @@ -3542,7 +3542,7 @@ func (s *apiSuite) TestRefreshBaseUpdate(c *check.C) {
},
}

s.b.GetBaseCallback = func(ctx context.Context, base string) (workshop.BaseImage, error) {
s.b.GetBaseCallback = func(ctx context.Context, confinement workshop.Confinement, base string) (workshop.BaseImage, error) {
return workshop.BaseImage{Name: base, Fingerprint: "newimage321"}, nil
}

Expand Down
13 changes: 4 additions & 9 deletions internal/daemon/snapshot-format.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,20 @@ system:
- drwxr-xr-x var
- drwxr-xr-x var/lib
- drwxr-xr-x var/lib/workshop
- drwxr-xr-x var/lib/workshop/run
- drwxr-xr-x var/lib/workshop/sdk
- Lrwxrwxrwx var/lib/workshop/sdk/system
tasks:
create-workshop: 1
start-workshop: 1
install-sdk: 1
run-hook: 1
fs-calls: 5
fs-calls: 4
exec-calls: []
test-sdk-3:
files:
- drwxr-xr-x var
- drwxr-xr-x var/lib
- drwxr-xr-x var/lib/workshop
- drwxr-xr-x var/lib/workshop/run
- drwxr-xr-x var/lib/workshop/sdk
- Lrwxrwxrwx var/lib/workshop/sdk/system
- Lrwxrwxrwx var/lib/workshop/sdk/test-sdk-3
Expand All @@ -34,14 +32,13 @@ test-sdk-3:
install-sdk: 2
run-hook: 2
snapshot-sdk: 1
fs-calls: 9
fs-calls: 8
exec-calls: []
test-sdk-2:
files:
- drwxr-xr-x var
- drwxr-xr-x var/lib
- drwxr-xr-x var/lib/workshop
- drwxr-xr-x var/lib/workshop/run
- drwxr-xr-x var/lib/workshop/sdk
- Lrwxrwxrwx var/lib/workshop/sdk/system
- Lrwxrwxrwx var/lib/workshop/sdk/test-sdk-2
Expand All @@ -52,15 +49,14 @@ test-sdk-2:
install-sdk: 3
run-hook: 3
snapshot-sdk: 2
fs-calls: 13
fs-calls: 12
exec-calls:
- ["sudo", "--user=#0", "--group=#0", "--preserve-env=SDK", "--preserve-env=WORKSHOP_COOKIE", "--", "bash", "-l", "-c", 'exec -- "$0" "$@"', "bash", "-o", "errexit", "-o", "pipefail", "/var/lib/workshop/sdk/test-sdk-2/sdk/hooks/setup-base"]
test-sdk:
files:
- drwxr-xr-x var
- drwxr-xr-x var/lib
- drwxr-xr-x var/lib/workshop
- drwxr-xr-x var/lib/workshop/run
- drwxr-xr-x var/lib/workshop/sdk
- Lrwxrwxrwx var/lib/workshop/sdk/system
- Lrwxrwxrwx var/lib/workshop/sdk/test-sdk
Expand All @@ -72,7 +68,7 @@ test-sdk:
install-sdk: 4
run-hook: 4
snapshot-sdk: 3
fs-calls: 17
fs-calls: 16
exec-calls:
- ["sudo", "--user=#0", "--group=#0", "--preserve-env=SDK", "--preserve-env=WORKSHOP_COOKIE", "--", "bash", "-l", "-c", 'exec -- "$0" "$@"', "bash", "-o", "errexit", "-o", "pipefail", "/var/lib/workshop/sdk/test-sdk-2/sdk/hooks/setup-base"]
- ["sudo", "--user=#0", "--group=#0", "--preserve-env=SDK", "--preserve-env=WORKSHOP_COOKIE", "--", "bash", "-l", "-c", 'exec -- "$0" "$@"', "bash", "-o", "errexit", "-o", "pipefail", "/var/lib/workshop/sdk/test-sdk/sdk/hooks/setup-base"]
Expand All @@ -81,7 +77,6 @@ sketch:
- drwxr-xr-x var
- drwxr-xr-x var/lib
- drwxr-xr-x var/lib/workshop
- drwxr-xr-x var/lib/workshop/run
- drwxr-xr-x var/lib/workshop/sdk
- Lrwxrwxrwx var/lib/workshop/sdk/sketch
- Lrwxrwxrwx var/lib/workshop/sdk/system
Expand Down
Loading
Loading