Skip to content
Merged
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
2 changes: 1 addition & 1 deletion internal/daemon/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ var api = []*Command{{
Path: "/v1/workshopctl",
UserOK: true,
UntrustedOK: true,
POST: v1PostWorkshopCtl,
POST: withWorkshopInstanceID(v1PostWorkshopCtl),
},
}

Expand Down
45 changes: 45 additions & 0 deletions internal/daemon/api_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 daemon

import (
"context"
"net/http"

"github.com/canonical/workshop/internal/workshop"
)

// workshopInstanceIDHeader identifies the workshop instance from which an API
// request originated.
const workshopInstanceIDHeader = "workshop-instance-id"

// withWorkshopInstanceID returns a response function that adds the workshop
// instance ID header value to the request context when the header is present,
// then calls next.
func withWorkshopInstanceID(next ResponseFunc) ResponseFunc {
return func(c *Command, r *http.Request, user *userState) Response {
instanceID := r.Header.Get(workshopInstanceIDHeader)
if instanceID != "" {
ctx := context.WithValue(
r.Context(),
workshop.ContextWorkshopInstanceID,
instanceID,
)
r = r.WithContext(ctx)
}

return next(c, r, user)
}
}
126 changes: 126 additions & 0 deletions internal/daemon/api_request_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// 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 daemon

import (
"net/http"
"net/http/httptest"

"gopkg.in/check.v1"

"github.com/canonical/workshop/internal/workshop"
)

type apiRequestSuite struct{}

var _ = check.Suite(&apiRequestSuite{})

// TestWithWorkshopInstanceIDAddsIDToContext checks that the middleware copies
// the workshop instance ID header into the request context before calling the
// next response function. It asserts that the next function's response is
// returned and that the context contains the header value.
func (apiRequestSuite) TestWithWorkshopInstanceIDAddsIDToContext(c *check.C) {
request := httptest.NewRequest(http.MethodPost, "/v1/workshopctl", nil)
request.Header.Set(workshopInstanceIDHeader, "instance-id")

var instanceID string
next := func(_ *Command, request *http.Request, _ *userState) Response {
instanceID, _ = request.Context().
Value(workshop.ContextWorkshopInstanceID).(string)
return nil
}

response := withWorkshopInstanceID(next)(nil, request, nil)

c.Check(response, check.IsNil)
c.Check(instanceID, check.Equals, "instance-id")
}

// TestWithWorkshopInstanceIDLeavesContextUnsetWithoutHeader checks that the
// middleware calls the next response function without adding a workshop
// instance ID when the header is absent. It asserts that the next function's
// response is returned and that the context value remains unset.
func (apiRequestSuite) TestWithWorkshopInstanceIDLeavesContextUnsetWithoutHeader(
c *check.C,
) {
request := httptest.NewRequest(http.MethodPost, "/v1/workshopctl", nil)

var instanceID any
next := func(_ *Command, request *http.Request, _ *userState) Response {
instanceID = request.Context().Value(workshop.ContextWorkshopInstanceID)
return nil
}

response := withWorkshopInstanceID(next)(nil, request, nil)

c.Check(response, check.IsNil)
c.Check(instanceID, check.IsNil)
}

// TestWithWorkshopInstanceIDCallsNextWithHeader checks that the middleware
// calls the next response function and returns its response when the workshop
// instance ID header is present.
func (apiRequestSuite) TestWithWorkshopInstanceIDCallsNextWithHeader(c *check.C) {
command := &Command{}
request := httptest.NewRequest(http.MethodPost, "/v1/workshopctl", nil)
request.Header.Set(workshopInstanceIDHeader, "instance-id")
user := &userState{}
expected := SyncResponse(nil, http.StatusAccepted)

called := false
next := func(
actualCommand *Command,
_ *http.Request,
actualUser *userState,
) Response {
called = true
c.Check(actualCommand, check.Equals, command)
c.Check(actualUser, check.Equals, user)
return expected
}

response := withWorkshopInstanceID(next)(command, request, user)

c.Check(called, check.Equals, true)
c.Check(response, check.Equals, expected)
}

// TestWithWorkshopInstanceIDCallsNextWithoutHeader checks that the middleware
// calls the next response function and returns its response when the workshop
// instance ID header is absent.
func (apiRequestSuite) TestWithWorkshopInstanceIDCallsNextWithoutHeader(c *check.C) {
command := &Command{}
request := httptest.NewRequest(http.MethodPost, "/v1/workshopctl", nil)
user := &userState{}
expected := SyncResponse(nil, http.StatusAccepted)

called := false
next := func(
actualCommand *Command,
actualRequest *http.Request,
actualUser *userState,
) Response {
called = true
c.Check(actualCommand, check.Equals, command)
c.Check(actualRequest, check.Equals, request)
c.Check(actualUser, check.Equals, user)
return expected
}

response := withWorkshopInstanceID(next)(command, request, user)

c.Check(called, check.Equals, true)
c.Check(response, check.Equals, expected)
}
1 change: 1 addition & 0 deletions internal/daemon/snapshot-ingredients.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ SdkRecord:
Workshop:
Backend:
Project: workshop.Project
InstanceID: string
File: '*workshop.File'
Name: string
Format: sdk.Revision
Expand Down
8 changes: 8 additions & 0 deletions internal/workshop/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ import (

type ContextKeyProjectId string
type ContextKeyUser string
type ContextKeyWorkshopInstanceID string

const (
ContextProjectId = ContextKeyProjectId("project-id")
ContextUser = ContextKeyUser("user")
// ContextWorkshopInstanceID stores the identifier of the workshop instance
// from which a request originated.
ContextWorkshopInstanceID = ContextKeyWorkshopInstanceID("workshop-instance-id")

Uid = 1000
Gid = 1000
Expand Down Expand Up @@ -247,6 +251,10 @@ type Backend interface {
// has a username key that the corresponding projects belong to.
Projects(ctx context.Context) (map[string][]Project, error)

// Returns the projects belonging to the user in context. If the context
// does not contain a user, it returns an empty slice.
UserProjects(ctx context.Context) ([]Project, error)

// Loads a workshop instance.
Workshop(ctx context.Context, name string) (*Workshop, error)

Expand Down
14 changes: 13 additions & 1 deletion internal/workshop/fakebackend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,23 @@ func (s *FakeWorkshopBackend) CreateOrLoadProject(ctx context.Context, path stri
func (f *FakeWorkshopBackend) Projects(ctx context.Context) (map[string][]workshop.Project, error) {
userName, ok := ctx.Value(workshop.ContextUser).(string)
if ok {
return map[string][]workshop.Project{userName: f.projects[userName]}, nil
projects, err := f.UserProjects(ctx)
if err != nil {
return nil, err
}
return map[string][]workshop.Project{userName: projects}, nil
}
return maps.Clone(f.projects), nil
}

func (f *FakeWorkshopBackend) UserProjects(ctx context.Context) ([]workshop.Project, error) {
userName, ok := ctx.Value(workshop.ContextUser).(string)
if !ok {
return nil, nil
}
return slices.Clone(f.projects[userName]), nil
}

func (f *FakeWorkshopBackend) project(user, id string) *workshop.Project {
prjs := f.projects[user]
idx := slices.IndexFunc(prjs, func(p workshop.Project) bool { return p.ProjectId == id })
Expand Down
31 changes: 21 additions & 10 deletions internal/workshop/lxd/lxd_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ const (
storagePool = "workshop"
storagePoolMinimalGiB = 5

// instanceUUIDConfigKey identifies the LXD configuration value used to
// derive the workshop's machine ID.
instanceUUIDConfigKey = "volatile.uuid"

networkName = "workshopbr0"
networkType = "bridge"

Expand Down Expand Up @@ -1063,19 +1067,26 @@ func (b *Backend) loadWorkshop(conn lxd.InstanceServer, inst *api.Instance, p wo
hostname := b.hostname(f.Name, p, running, cnames)

return &workshop.Workshop{
Backend: b,
Project: p,
Name: f.Name,
Format: format,
Image: image,
Running: running,
Sdks: sdks,
Profiles: profs,
File: f,
Hostname: hostname,
Backend: b,
Project: p,
InstanceID: instanceIDFromLXDUUID(inst.Config[instanceUUIDConfigKey]),
Name: f.Name,
Format: format,
Image: image,
Running: running,
Sdks: sdks,
Profiles: profs,
File: f,
Hostname: hostname,
}, nil
}

// instanceIDFromLXDUUID converts an LXD instance UUID to the identifier
// written to the workshop's machine ID file.
func instanceIDFromLXDUUID(uuid string) string {
return strings.ReplaceAll(uuid, "-", "")
}

func (s *Backend) hostname(name string, p workshop.Project, running bool, cnames []cname) workshop.Hostname {
var hostname workshop.Hostname
if cnames == nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/workshop/lxd/lxd_backend_dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func (s *Backend) addWorkshopCNAMEs(conn lxd.InstanceServer, ctx context.Context
}

// Call this before locking because it might prune the dnsmasq config.
projects, err := s.userProjects(ctx)
projects, err := s.UserProjects(ctx)
if err != nil {
return err
}
Expand Down
10 changes: 7 additions & 3 deletions internal/workshop/lxd/lxd_backend_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func (s *Backend) CreateOrLoadProject(ctx context.Context, path string) (*worksh

func (s *Backend) Projects(ctx context.Context) (map[string][]workshop.Project, error) {
if user, ok := ctx.Value(workshop.ContextUser).(string); ok {
projects, err := s.userProjects(ctx)
projects, err := s.UserProjects(ctx)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -203,7 +203,7 @@ func (s *Backend) Projects(ctx context.Context) (map[string][]workshop.Project,
}

prjctx := context.WithValue(ctx, workshop.ContextUser, username)
projects, err := s.userProjects(prjctx)
projects, err := s.UserProjects(prjctx)
if err != nil {
return nil, err
}
Expand All @@ -213,7 +213,11 @@ func (s *Backend) Projects(ctx context.Context) (map[string][]workshop.Project,
return allProjects, nil
}

func (s *Backend) userProjects(ctx context.Context) ([]workshop.Project, error) {
func (s *Backend) UserProjects(ctx context.Context) ([]workshop.Project, error) {
if _, ok := ctx.Value(workshop.ContextUser).(string); !ok {
return nil, nil

@dmitry-lyfar dmitry-lyfar Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil, nil
return []workshop.Project{}, nil

The interface's comment says it would return an empty slice, though, it's probably equivalent behavior here. I'm guessing the difference will show up if this would make it as a return value for /v1/projects (not quite likely either given it would be transformed into an API level struct).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be inclined to leave it as is. len(nil) still returns 0 and append operations still work. i.e nill is safe value for slices in go.

}

client, err := s.LxdClient(ctx)
if err != nil {
return nil, err
Expand Down
40 changes: 39 additions & 1 deletion internal/workshop/lxd/tests/integration/workshop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,23 @@ func (f *wsOps) SetUpSuite(c *check.C) {
f.bd, err = lxdbackend.New()
c.Assert(err, check.IsNil)

f.usr = &user.User{Username: "testuser", Uid: "1000", Gid: "1000", HomeDir: c.MkDir()}
currentUser, err := user.Current()
c.Assert(err, check.IsNil)
uid := currentUser.Uid
gid := currentUser.Gid
// Spread runs integration tests as root, but mapping host root into an
// unprivileged instance prevents LXD from starting it. Retain the historical
// test IDs for root while allowing non-root developers to use their own IDs.
if os.Geteuid() == 0 {
uid = workshop.User.Uid
gid = workshop.User.Gid
}
f.usr = &user.User{
Username: "testuser",
Uid: uid,
Gid: gid,
HomeDir: c.MkDir(),
}
f.project = workshop.Project{
ProjectId: "42424242",
Path: filepath.Join(c.MkDir(), "testprj"),
Expand Down Expand Up @@ -233,6 +249,28 @@ func fullInstance(c *check.C, conn lxd.InstanceServer, name string) *api.Instanc
return inst
}

// TestLxdBackendWorkshopInstanceID checks that loading a workshop exposes the
// LXD instance UUID in the same normalized form written to its machine ID file.
func (f *wsOps) TestLxdBackendWorkshopInstanceID(c *check.C) {
helper.LaunchTestWorkshop(c, f.ctx, f.bd, f.project.Path)
defer helper.RemoveTestWorkshop(c, f.ctx, f.bd)

loaded, err := f.bd.Workshop(f.ctx, "test")
c.Assert(err, check.IsNil)

conn, err := f.bd.LxdClient(f.ctx)
c.Assert(err, check.IsNil)
defer conn.Disconnect()

inst, _, err := conn.GetInstance(
lxdbackend.InstanceName("test", f.project.ProjectId),
)
c.Assert(err, check.IsNil)
expected := strings.ReplaceAll(inst.Config["volatile.uuid"], "-", "")

Comment on lines +269 to +270
c.Check(loaded.InstanceID, check.Equals, expected)
}

func includeWhenCopying(key string) bool {
if strings.HasPrefix(key, "user.ed25519-key.") {
return false
Expand Down
3 changes: 3 additions & 0 deletions internal/workshop/workshop.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ var InstallTimeNow = time.Now
type Workshop struct {
Backend Backend
Project Project
// InstanceID uniquely identifies the running backend instance. It is used
// to associate requests originating inside the workshop with this record.
InstanceID string
// Workshop file that was used to launch it; it may be out of sync with the
// file in the project directory due to user's edits, etc.
File *File
Expand Down
Loading