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
12 changes: 7 additions & 5 deletions TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,16 @@ the rollback, and destroying the instance destroys the enrolled key. 5 holds bec
provisioned VM hosts one workroom; the helper is global on purpose, since it must outrank boxd's
system helper and answer the first clone, before any repository exists.

**How to start:** revisit 1, 2 and 4 when #252 (derivation) lands; stage a pending key beside the
current one and swap on success for 2, and have provisioning keep the grant ID when a cancel fails
for 4. For 3, sign in on a Dev build and on a Developer ID Nightly once codaset.dev serves the
**How to start:** #252 (derivation) settles part of 1 and 4. For 1, `ContainerHostDriver` refuses
to derive from an instance; every later driver needs the same refusal, and nothing stops a copy
made outside Workroom. 4 is fixed: `AgentEnrolment.enrol` throws `GrantStillLive` with the grant ID when its own cancel
fails, and provisioning passes it on in `RemoteProvisioning.Failure.rollbackIncomplete`. For 2,
stage a pending key beside the current one and swap on success. For 3, sign in on a Dev build and on a Developer ID Nightly once codaset.dev serves the
broker, and check the item's access list in Keychain Access. Revisit 5 if enrolment ever runs on a
bring-your-own host.

**Depends on / blocked by:** #252 for 1, 2 and 4; codaset#43 deployed for 3; a BYO-host enrolment
path for 5.
**Depends on / blocked by:** nothing for 2 (#252 has landed); codaset#43 deployed for 3; a BYO-host
enrolment path for 5.

**Priority:** P2, effort S.

Expand Down
34 changes: 29 additions & 5 deletions cmd/delete_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,9 @@ func runDeleteProject(svc *workroom.Service, jsonMode bool, confirm string, with
// Refused in every mode, before anything is torn down. --from-disk's safety gate reasons
// about local paths and --with-workrooms tears down locally; the config-only mode would drop
// the only record of a remote machine. Remote deletion is its own path (#253).
projects, err := svc.Config.AllProjects()
if err != nil {
if err := refuseRemoteProject(svc.Config, canon); err != nil {
return err
}
if remote := projects[canon].RemoteWorkroomNames(); len(remote) > 0 {
return fmt.Errorf("%w: %s has remote workrooms: %s", errs.ErrRemoteWorkroom, canon, strings.Join(remote, ", "))
}

if fromDisk {
// Guard: refuse obviously dangerous paths.
Expand Down Expand Up @@ -129,6 +125,10 @@ func runDeleteProject(svc *workroom.Service, jsonMode bool, confirm string, with
logWriter := newJSONLogWriter(logSink, "teardown")
svc.ScriptLogWriter = logWriter
for _, name := range names {
if err := refuseRemoteProject(svc.Config, canon); err != nil {
logWriter.Flush()
return err
}
if err := svc.RunTeardown(canon, name); err != nil {
logWriter.Flush()
return err
Expand Down Expand Up @@ -161,6 +161,14 @@ func runDeleteProject(svc *workroom.Service, jsonMode bool, confirm string, with
logWriter := newJSONLogWriter(logSink, "teardown")
svc.ScriptLogWriter = logWriter
for _, name := range names {
// Again before each workroom: a descriptor written during the cascade (a base
// recorded) stops it here rather than after every workroom has gone.
// RemoveProject checks once more under its own lock; only a descriptor written
// during one workroom's teardown gets past these.
if err := refuseRemoteProject(svc.Config, canon); err != nil {
logWriter.Flush()
return err
}
if err := svc.Delete(canon, name, name); err != nil {
logWriter.Flush()
return err
Expand Down Expand Up @@ -255,3 +263,19 @@ func init() {
deleteProjectCmd.Flags().BoolVar(&deleteProjectFromDisk, "from-disk", false, "App-only: runs teardowns, drops config, returns trash_paths; the caller (macOS app) moves those dirs to the Trash")
rootCmd.AddCommand(deleteProjectCmd)
}

// refuseRemoteProject refuses a project with remote workrooms, or with its own descriptor, which
// records its base machine (#252): either is the only record of a remote machine.
func refuseRemoteProject(cfg *config.Config, canon string) error {
projects, err := cfg.AllProjects()
if err != nil {
return err
}
if remote := projects[canon].RemoteWorkroomNames(); len(remote) > 0 {
return fmt.Errorf("%w: %s has remote workrooms: %s", errs.ErrRemoteWorkroom, canon, strings.Join(remote, ", "))
}
if projects[canon].Host != nil {
return fmt.Errorf("%w: %s has a remote host (its base machine)", errs.ErrRemoteWorkroom, canon)
}
return nil
}
64 changes: 64 additions & 0 deletions cmd/delete_project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type fakeVCS struct {
deleteCalls []string
failOn string // vcsName to fail on (e.g. "workroom/bravo"); "" never fails
list []string // names ListWorkrooms returns (Service.Delete's existence check)
onDelete func() // runs after each Delete, to change config mid-cascade
}

func (f *fakeVCS) Type() vcs.Type { return vcs.TypeGit }
Expand All @@ -32,6 +33,9 @@ func (f *fakeVCS) Create(_, _, _ string) (string, error) { return "", nil }
func (f *fakeVCS) ListWorkrooms(_ string) ([]string, error) { return f.list, nil }
func (f *fakeVCS) Delete(_, vcsName, _ string) (string, error) {
f.deleteCalls = append(f.deleteCalls, vcsName)
if f.onDelete != nil {
f.onDelete()
}
if f.failOn != "" && vcsName == f.failOn {
return "", errors.New("boom")
}
Expand Down Expand Up @@ -432,6 +436,66 @@ func TestUnsafeProjectDeletePath(t *testing.T) {
}
}

// TestDeleteProjectRefusesAProjectWithABase: a project's own host descriptor records its base
// machine (#252), so every mode refuses a project carrying one even with no remote workroom,
// rather than dropping the only record of a running box.
func TestDeleteProjectRefusesAProjectWithABase(t *testing.T) {
for _, mode := range []struct {
name string
withWR, fromDisk bool
}{{"config-only", false, false}, {"with-workrooms", true, false}, {"from-disk", false, true}} {
t.Run(mode.name, func(t *testing.T) {
fake := &fakeVCS{list: []string{"alpha"}}
svc, cfg := newTestSvc(t, fake)
proj := t.TempDir()
canon, _ := config.CanonicalPath(proj)
cfg.AddWorkroom(canon, "alpha", "/wr/alpha", "git")
if err := cfg.SetHost(canon, "", map[string]any{"base": map[string]any{"host": "h1"}}); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(cfg.Path())

err := runDeleteProject(svc, true, canon, mode.withWR, mode.fromDisk, []string{proj}, &bytes.Buffer{}, &bytes.Buffer{})
if !errors.Is(err, errs.ErrRemoteWorkroom) {
t.Fatalf("expected ErrRemoteWorkroom, got %v", err)
}
if len(fake.deleteCalls) != 0 {
t.Fatalf("workrooms were torn down: %v", fake.deleteCalls)
}
if after, _ := os.ReadFile(cfg.Path()); !bytes.Equal(before, after) {
t.Fatalf("config changed:\n%s", after)
}
})
}
}

// TestDeleteProjectStopsWhenABaseIsRecordedMidCascade: a host descriptor written while
// --with-workrooms tears down (a base recorded) stops the cascade before the next workroom, not
// only at the final config write, so no more local workrooms go than had already gone.
func TestDeleteProjectStopsWhenABaseIsRecordedMidCascade(t *testing.T) {
fake := &fakeVCS{list: []string{"alpha", "bravo", "charlie"}}
svc, cfg := newTestSvc(t, fake)
proj := t.TempDir()
canon, _ := config.CanonicalPath(proj)
for _, name := range []string{"alpha", "bravo", "charlie"} {
cfg.AddWorkroom(canon, name, filepath.Join(t.TempDir(), name), "git")
}
fake.onDelete = func() {
fake.onDelete = nil
if err := cfg.SetHost(canon, "", map[string]any{"base": map[string]any{"host": "h1"}}); err != nil {
t.Fatal(err)
}
}

err := runDeleteProject(svc, true, canon, true, false, []string{proj}, &bytes.Buffer{}, &bytes.Buffer{})
if !errors.Is(err, errs.ErrRemoteWorkroom) {
t.Fatalf("expected ErrRemoteWorkroom, got %v", err)
}
if len(fake.deleteCalls) != 1 {
t.Fatalf("the cascade went on after the base was recorded: %v", fake.deleteCalls)
}
}

// TestDeleteProjectRefusesRemoteWorkrooms: every mode refuses a project with remote workrooms,
// naming them, before tearing anything down or touching config.
func TestDeleteProjectRefusesRemoteWorkrooms(t *testing.T) {
Expand Down
112 changes: 112 additions & 0 deletions cmd/host.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package cmd

import (
"encoding/json"
"fmt"
"io"
"os"
"strings"

"github.com/joelmoss/workroom/internal/config"
"github.com/joelmoss/workroom/internal/errs"
"github.com/spf13/cobra"
)

var (
hostProject string
hostWorkroom string
)

// hostCmd records the host descriptors the desktop app owns (#249, #252): which machine a remote
// workroom lives on, and a project's base machine. The descriptor is stored verbatim; the CLI
// interprets only whether a workroom has one and its "state".
var hostCmd = &cobra.Command{
Use: "host",
Short: "Set or clear a project's or workroom's host descriptor (used by the desktop app)",
Hidden: true,
}

var hostSetCmd = &cobra.Command{
Use: "set DESCRIPTOR",
Short: "Store DESCRIPTOR, a JSON object, as the host descriptor",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
currentCommand = "host"
host, err := decodeHost(args[0])
if err != nil {
return err
}
return setHost(host)
},
}

var hostClearCmd = &cobra.Command{
Use: "clear",
Short: "Remove the host descriptor",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
currentCommand = "host"
return setHost(nil)
},
}

// decodeHost reads a descriptor as written: UseNumber, as Config.Read does, so an integer above
// 2^53 is not rewritten through float64. A JSON null decodes to a nil map, which would clear the
// descriptor, so it is refused with every other non-object, and so is trailing data.
func decodeHost(text string) (map[string]any, error) {
var host map[string]any
dec := json.NewDecoder(strings.NewReader(text))
dec.UseNumber()
if err := dec.Decode(&host); err != nil || host == nil || dec.Decode(new(any)) != io.EOF {
return nil, fmt.Errorf("%w: %s", errs.ErrInvalidHost, text)
}
return host, nil
}

func setHost(host map[string]any) error {
cfg, err := config.New("")
if err != nil {
return err
}
dir, err := resolveProject(hostProject)
if err != nil {
return err
}
// --pretend checks the descriptor and that its entry exists, and writes nothing.
if pretend {
if err := hostEntryExists(cfg, dir, hostWorkroom); err != nil {
return err
}
} else if err := cfg.SetHost(dir, hostWorkroom, host); err != nil {
return err
}
if jsonOutput {
return writeJSONSuccess(os.Stdout, "host", map[string]any{"project": dir, "workroom": hostWorkroom})
}
return nil
}

// hostEntryExists refuses an entry SetHost would refuse, for --pretend.
func hostEntryExists(cfg *config.Config, dir, workroom string) error {
projects, err := cfg.AllProjects()
if err != nil {
return err
}
project, ok := projects[dir]
if !ok {
return fmt.Errorf("%w: %s", errs.ErrProjectNotFound, dir)
}
if _, ok := project.Workrooms[workroom]; workroom != "" && !ok {
return fmt.Errorf("%w: '%s' in %s", errs.ErrWorkroomNotFound, workroom, dir)
}
return nil
}

func init() {
for _, c := range []*cobra.Command{hostSetCmd, hostClearCmd} {
c.Flags().StringVar(&hostProject, "project", "", "Project directory (defaults to the current directory)")
c.Flags().StringVar(&hostWorkroom, "workroom", "", "The project's workroom; omitted, the project's own descriptor")
hostCmd.AddCommand(c)
}
rootCmd.AddCommand(hostCmd)
}
Loading
Loading