diff --git a/.agents/skills/add-mission/SKILL.md b/.agents/skills/add-mission/SKILL.md index c7537a5..c08b845 100644 --- a/.agents/skills/add-mission/SKILL.md +++ b/.agents/skills/add-mission/SKILL.md @@ -12,7 +12,7 @@ Read [references/mission-checklist.md](references/mission-checklist.md) before e 1. Inspect the current state. - Read `git status` and preserve unrelated changes. - Read the missions immediately before and after the intended catalog position in `internal/mission/data/`. - - Read `internal/mission/mission.go`, `internal/mission/catalog.go`, `internal/game/validator.go`, `internal/mission/catalog_test.go`, and `internal/game/missions_test.go`. + - Read `internal/mission/mission.go`, `validate.go`, `conditions.go`, `docker_rules.go`, `internal/game/validator.go`, the matching `internal/mission/*_test.go`, and `internal/game/missions_test.go` with the Linux or Docker route tests. - Check the track-local world/campaign order, stage placement, difficulty and XP progression, existing validators, and test patterns before choosing an implementation. 2. State the learning objective before writing JSON. diff --git a/.agents/skills/add-mission/references/mission-checklist.md b/.agents/skills/add-mission/references/mission-checklist.md index 23db58b..63f2c03 100644 --- a/.agents/skills/add-mission/references/mission-checklist.md +++ b/.agents/skills/add-mission/references/mission-checklist.md @@ -2,7 +2,7 @@ ## Current content model -Missions are embedded JSON files under `internal/mission/data/`. `internal/mission/catalog.go` decodes them with unknown fields rejected, validates each item, sorts by `number`, and requires a contiguous catalog beginning at 1. `internal/mission/world.go` derives track-local worlds from contiguous campaign runs; a campaign cannot disappear and later reappear in the same track. +Missions are embedded JSON files under `internal/mission/data/`. `internal/mission/catalog.go` decodes them with unknown fields rejected, validates each item through `validate.go`, `conditions.go`, and `docker_rules.go`, sorts by `number`, and requires a contiguous catalog beginning at 1. `internal/mission/world.go` derives track-local worlds from contiguous campaign runs; a campaign cannot disappear and later reappear in the same track. Every mission supplies: diff --git a/.agents/skills/extend-sandbox-command/SKILL.md b/.agents/skills/extend-sandbox-command/SKILL.md index ab1eb8f..52f6ac0 100644 --- a/.agents/skills/extend-sandbox-command/SKILL.md +++ b/.agents/skills/extend-sandbox-command/SKILL.md @@ -10,7 +10,7 @@ Read [references/shell-semantics.md](references/shell-semantics.md) before chang ## Workflow 1. Map the existing behavior. - - Inspect `internal/sandbox/shell.go`, `sandbox.go`, `filesystem.go`, the relevant `commands_*.go`, and `shell_test.go`. + - Inspect `internal/sandbox/shell.go` (execution stages), `lexer.go`, `command_line.go`, `dispatch.go` (command registration), `manuals.go` (help), `sandbox.go`, `filesystem*.go`, the relevant `commands_*.go`, and `shell_test.go`. - Trace lexing, parsing, word expansion, stage execution, dispatcher registration, virtual path resolution, and error wrapping. - Compare a semantically similar command and read its focused manual entry. Check missions and canonical solutions that already depend on the affected behavior. @@ -28,7 +28,7 @@ Read [references/shell-semantics.md](references/shell-semantics.md) before chang - Put command behavior in the closest `commands_*.go` file or a narrowly named new file. - Register the command in `Sandbox.run` and in the no-argument command list. - Add or update its `commandManuals` entry so `help COMMAND` and `man COMMAND` describe the actual subset. - - Keep reusable virtual-filesystem behavior in `filesystem.go`; do not bypass it inside a command handler. + - Keep reusable virtual-filesystem behavior in `filesystem*.go`; do not bypass it inside a command handler. 5. Add tests before relying on the command in content. - Cover successful direct use and stdin/pipeline use where applicable. diff --git a/.agents/skills/prepare-iteration/SKILL.md b/.agents/skills/prepare-iteration/SKILL.md index a733fbc..07e9abc 100644 --- a/.agents/skills/prepare-iteration/SKILL.md +++ b/.agents/skills/prepare-iteration/SKILL.md @@ -10,7 +10,7 @@ Use [references/iteration-template.md](references/iteration-template.md) for the ## Workflow 1. Establish the milestone boundary. - - Read the reports in `project/history/iterations/`, README, `internal/cli/app.go`, and the milestone request. + - Read the reports in `project/history/iterations/`, README, the command files in `internal/cli/`, and the milestone request. - Identify the next iteration number and intended version. Do not change version strings or roadmap claims unless the milestone requires it. 2. Review the complete change set. diff --git a/docs/technical/sandbox-and-safety.md b/docs/technical/sandbox-and-safety.md index b914799..b42fe1c 100644 --- a/docs/technical/sandbox-and-safety.md +++ b/docs/technical/sandbox-and-safety.md @@ -171,20 +171,20 @@ The Docker-compatible engine remains a powerful external dependency; OpsQuest re | Threat | Primary controls | Evidence location | | --- | --- | --- | -| Player launches a host command | Closed Go dispatcher; no fallback process lookup; raw line never reaches a shell | `internal/sandbox/shell.go`, hardening tests | -| Player reads or writes a host path | Independent in-memory root and virtual path resolver | `internal/sandbox/filesystem.go`, regression tests | +| Player launches a host command | Closed Go dispatcher; no fallback process lookup; raw line never reaches a shell | `internal/sandbox/shell.go`, `dispatch.go`, hardening tests | +| Player reads or writes a host path | Independent in-memory root and virtual path resolver | `internal/sandbox/filesystem*.go`, regression tests | | Expansion exhausts memory | Line, token, argument, output, entry, and aggregate budgets | quota tests in `internal/sandbox` | | Archive escapes extraction target | Strict metadata validation and destination containment | archive and hardening tests | | Script bypasses shell restrictions | Same parser/dispatcher, bounded nesting and steps, unsupported syntax rejection | `commands_script.go` and tests | | Docker input becomes arbitrary CLI flags | Typed parser accepts only exact actions and validated logical aliases | `internal/dockerlab/parser.go` and tests | -| Cleanup removes another container | Exact ID plus managed/schema/session/mission/alias label verification | `internal/dockerlab/environment.go` and tests | -| Partial Docker setup leaks resources silently | Factory may return a partial environment; managed cleanup and retryable `Close` | `internal/dockerlab/factory.go`, environment contract tests | +| Cleanup removes another container | Exact ID plus managed/schema/session/mission/alias label verification | `internal/dockerlab/cleanup.go`, `ownership.go` and tests | +| Partial Docker setup leaks resources silently | Factory may return a partial environment; managed cleanup and retryable `Close` | `internal/dockerlab/factory.go`, `fixtures.go`, environment contract tests | | A killed process leaks running fixtures | 24-hour fixture lifetime; owner-process and age-based sweep with complete label and generated-name proof | `internal/dockerlab/janitor.go` and tests | | Orphan sweep removes a live or foreign container | Live same-host owners are never swept; unknown owners only after the fixture lifetime; exact IDs only | `internal/dockerlab/janitor_test.go` | -| Removed containers leak engine identity | Missing-container errors mapped to the alias; tracked IDs and names redacted from engine errors | `internal/dockerlab/environment.go` and tests | +| Removed containers leak engine identity | Missing-container errors mapped to the alias; tracked IDs and names redacted from engine errors | `internal/dockerlab/transport.go`, `container_commands.go` and tests | | Lab network reaches the host network or internet | Fixed `--internal` bridge networks; no network options; no in-container command execution | `internal/dockerlab/network.go`, `network_test.go` | -| Player attaches a lab container to a host or foreign network | Built-in names rejected by the parser; only tracked attempt networks resolve; exact IDs only | `internal/dockerlab/parser.go`, `network.go` and tests | -| Network cleanup strands or removes the wrong network | Containers removed first; label and generated-name verification; retryable unresolved set; janitor sweeps networks after containers | `internal/dockerlab/network.go`, `janitor.go` and tests | +| Player attaches a lab container to a host or foreign network | Built-in names rejected by the parser; only tracked attempt networks resolve; exact IDs only | `internal/dockerlab/parser.go`, `network_commands.go` and tests | +| Network cleanup strands or removes the wrong network | Containers removed first; label and generated-name verification; retryable unresolved set; janitor sweeps networks after containers | `internal/dockerlab/cleanup.go`, `janitor.go` and tests | | Persisted display text injects terminal controls | Profile names reject non-printable characters and normalize legacy values | `internal/profile/profile.go` and tests | | Another site reaches the loopback companion | One-time capability pairing, exact Host/Origin checks, same-site HTTP-only cookie, no permissive CORS | `internal/webapp/server.go` and tests | | Browser input reaches a mission environment | Companion exposes only read-only state and SSE routes; `game.Session` has no companion command callback | `internal/webapp`, `internal/game/companion.go` | diff --git a/internal/cli/app.go b/internal/cli/app.go index d8e4407..f119e87 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1,13 +1,9 @@ package cli import ( - "bufio" "context" - "errors" - "flag" "fmt" "io" - "slices" "strings" "time" @@ -32,19 +28,6 @@ type App struct { errorStyle ui.Style } -type playRouteKind uint8 - -const ( - playRouteRecommended playRouteKind = iota - playRouteSequential - playRouteWorld -) - -type playRoute struct { - kind playRouteKind - worldNumber int -} - // Companion is the lifecycle and presentation boundary used by web-assisted // play. It receives sanitized game snapshots and never accepts player command // text or completion decisions. @@ -120,27 +103,6 @@ func (a *App) loadPlayer() (profile.Profile, error) { return player, nil } -func (a *App) newFlagSet(name string, usage func()) *flag.FlagSet { - flags := flag.NewFlagSet(name, flag.ContinueOnError) - flags.SetOutput(a.errOut) - flags.Usage = usage - return flags -} - -func parseFlags(flags *flag.FlagSet, args []string) (bool, error) { - err := flags.Parse(args) - if errors.Is(err, flag.ErrHelp) { - return true, nil - } - return false, err -} - -func flagProvided(flags *flag.FlagSet, name string) bool { - provided := false - flags.Visit(func(item *flag.Flag) { provided = provided || item.Name == name }) - return provided -} - func (a *App) Run(args []string) error { if len(args) == 0 { a.printUsage() @@ -177,814 +139,3 @@ func (a *App) Run(args []string) error { return fmt.Errorf("unknown command %q; run 'opsquest help'", args[0]) } } - -func (a *App) runPlay(args []string) (returnErr error) { - flags := a.newFlagSet("play", func() { a.printPlayUsage(a.errOut) }) - track := flags.String("track", mission.TrackLinux, "play the linux or docker track") - worldNumber := flags.Int("world", 0, "start the next incomplete stage in a world") - once := flags.Bool("once", false, "return after one completed mission") - web := flags.Bool("web", false, "show mission guidance in a local browser companion") - if help, err := parseFlags(flags, args); help || err != nil { - return err - } - if flags.NArg() > 1 { - return fmt.Errorf("usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [--web] [MISSION]") - } - trackProvided, worldProvided := flagProvided(flags, "track"), flagProvided(flags, "world") - selectedTrack := strings.ToLower(strings.TrimSpace(*track)) - if selectedTrack != mission.TrackLinux && selectedTrack != mission.TrackDocker { - return fmt.Errorf("unknown track %q; use linux or docker", *track) - } - if worldProvided && *worldNumber < 1 { - return fmt.Errorf("world number must be positive") - } - if flags.NArg() == 1 && (worldProvided || trackProvided) { - return fmt.Errorf("MISSION cannot be combined with --track or --world") - } - player, err := a.loadPlayer() - if err != nil { - return err - } - var item mission.Mission - route := playRoute{kind: playRouteRecommended} - if flags.NArg() == 1 { - var found bool - item, found = a.catalog.Find(flags.Arg(0)) - if !found { - return fmt.Errorf("mission %q not found; run 'opsquest list'", flags.Arg(0)) - } - route.kind = playRouteSequential - } else if *worldNumber > 0 { - route = playRoute{kind: playRouteWorld, worldNumber: *worldNumber} - var found bool - item, found = a.catalog.NextInWorld(selectedTrack, *worldNumber, player.IsComplete) - if !found { - if world, exists := a.catalog.World(selectedTrack, *worldNumber); exists && len(world.Missions) > 0 { - item, found = world.Missions[0], true - fmt.Fprintf(a.out, "%s\n", a.style.Accent(fmt.Sprintf("World %d is complete; replaying Stage 1.", *worldNumber))) - } - } - if !found { - return fmt.Errorf("world %d does not exist in the %s track; run 'opsquest map --track %s'", *worldNumber, selectedTrack, selectedTrack) - } - } else { - var found bool - item, found = a.catalog.NextInTrack(selectedTrack, player.IsComplete) - if !found { - fmt.Fprintf(a.out, "%s\n", a.style.Success(fmt.Sprintf("%s track complete! Replay a mission or inspect other worlds with 'opsquest map'.", trackDisplayName(selectedTrack)))) - return nil - } - } - var activeCompanion Companion - if *web { - activeCompanion, err = a.startCompanion(a.ctx) - if err != nil { - return fmt.Errorf("start web companion: %w", err) - } - defer func() { - closeContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if err := activeCompanion.Close(closeContext); err != nil { - returnErr = errors.Join(returnErr, fmt.Errorf("close web companion: %w", err)) - } - }() - fmt.Fprintln(a.out, a.style.Header("WEB MISSION COMPANION")) - fmt.Fprintf(a.out, "Open this one-time local URL in your browser:\n%s\n", a.style.Accent(activeCompanion.URL())) - fmt.Fprintln(a.out, a.style.Muted("Keep this terminal for commands. The browser can display progress but cannot execute anything.")) - } - if item.EffectiveTrack() == mission.TrackLinux && isPristineProfile(player) { - if !*web { - a.printQuickStart() - } - player.Onboarded = true - if err := a.store.Save(player); err != nil { - return err - } - } - continuous := !*once - reader := game.NewCommandLineReader(a.in, a.out) - for { - currentTrack := item.EffectiveTrack() - wasComplete := player.IsComplete(item.ID) - session := game.Session{ - Mission: item, - Player: &player, - Saver: a.store, - Reporter: activeCompanion, - Companion: activeCompanion != nil, - In: a.in, - Out: a.out, - ErrOut: a.errOut, - Reader: reader, - Catalog: a.catalog, - ListMissions: func(args []string) error { - if !hasFlag(args, "--track") { - args = append([]string{"--track", currentTrack}, args...) - } - return a.listMissions(args, player, true) - }, - Context: a.ctx, - Factory: a.factory, - Style: a.style, - ErrorStyle: a.errorStyle, - } - result, err := session.Run() - if err != nil { - return err - } - if result.SwitchMission != "" { - item, _ = a.catalog.Find(result.SwitchMission) - if result.WorldRoute > 0 { - route = playRoute{kind: playRouteWorld, worldNumber: result.WorldRoute} - } else { - route = playRoute{kind: playRouteSequential} - } - continue - } - if result.WorldRoute > 0 { - route = playRoute{kind: playRouteWorld, worldNumber: result.WorldRoute} - } - if !result.Completed { - return nil - } - if !continuous { - a.printNextRecommendation(item, player) - return nil - } - - next, found := a.nextOnRoute(route, item, player) - if !found { - a.printRouteFinished(route, item, player) - return nil - } - if current, ok := a.catalog.Placement(item.ID); ok { - if upcoming, ok := a.catalog.Placement(next.ID); ok && upcoming.WorldNumber != current.WorldNumber && !wasComplete && a.worldComplete(current, player) { - fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("✓ World %d complete: %s", current.WorldNumber, current.WorldName))) - transition := "Entering" - if world, found := a.catalog.World(upcoming.Track, upcoming.WorldNumber); found { - for _, stage := range world.Missions { - if player.IsComplete(stage.ID) { - transition = "Continuing in" - break - } - } - } - fmt.Fprintln(a.out, a.style.World(fmt.Sprintf("%s World %d: %s", transition, upcoming.WorldNumber, upcoming.WorldName))) - } - } - fmt.Fprintf(a.out, "\n%s\n", a.style.Accent(fmt.Sprintf("→ Continuing to Mission %02d: %s", next.Number, next.Title))) - item = next - } -} - -func (a *App) nextOnRoute(route playRoute, current mission.Mission, player profile.Profile) (mission.Mission, bool) { - switch route.kind { - case playRouteSequential: - return a.catalog.AdjacentInTrack(current.ID, 1) - case playRouteWorld: - next, found := a.catalog.AdjacentInTrack(current.ID, 1) - if !found { - return mission.Mission{}, false - } - placement, found := a.catalog.Placement(next.ID) - if !found || placement.Track != current.EffectiveTrack() || placement.WorldNumber != route.worldNumber { - return mission.Mission{}, false - } - return next, true - default: - return a.catalog.NextInTrack(current.EffectiveTrack(), player.IsComplete) - } -} - -func (a *App) printRouteFinished(route playRoute, current mission.Mission, player profile.Profile) { - if route.kind == playRouteWorld { - if placement, found := a.catalog.Placement(current.ID); found && a.worldComplete(placement, player) { - fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("World %d complete: %s!", placement.WorldNumber, placement.WorldName))) - return - } - fmt.Fprintf(a.out, "\n%s\n", a.style.Accent(fmt.Sprintf("Reached the end of World %d; unfinished stages remain. Resume with 'opsquest play --world %d'.", route.worldNumber, route.worldNumber))) - return - } - - if _, remaining := a.catalog.NextInTrack(current.EffectiveTrack(), player.IsComplete); remaining { - fmt.Fprintf(a.out, "\n%s\n", a.style.Accent("Reached the end of the selected route. Resume unfinished missions with 'opsquest play'.")) - return - } - fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("%s track complete!", trackDisplayName(current.EffectiveTrack())))) -} - -func (a *App) worldComplete(placement mission.Placement, player profile.Profile) bool { - world, found := a.catalog.World(placement.Track, placement.WorldNumber) - return found && completedMissions(world.Missions, player) == len(world.Missions) -} - -func (a *App) printNextRecommendation(completed mission.Mission, player profile.Profile) { - next, found := a.catalog.NextInTrack(completed.EffectiveTrack(), player.IsComplete) - if !found { - fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("%s track complete!", trackDisplayName(completed.EffectiveTrack())))) - return - } - placement, _ := a.catalog.Placement(next.ID) - fmt.Fprintf(a.out, "\n%s\n", a.style.Section("NEXT RECOMMENDED")) - fmt.Fprintf(a.out, "Mission %02d: %s · World %d, Stage %d/%d\n", next.Number, next.Title, placement.WorldNumber, placement.StageNumber, placement.StageTotal) - fmt.Fprintf(a.out, "Continue with %s, or jump anywhere with %s.\n", a.style.Accent("opsquest play"), a.style.Accent("opsquest map")) -} - -func (a *App) runList(args []string) error { - player, err := a.loadPlayer() - if err != nil { - return err - } - return a.listMissions(args, player, false) -} - -func (a *App) listMissions(args []string, player profile.Profile, inMission bool) error { - flags := a.newFlagSet("list", func() { a.printListUsage(a.errOut) }) - completedOnly := flags.Bool("completed", false, "show completed missions only") - remainingOnly := flags.Bool("remaining", false, "show incomplete missions only") - showIDs := flags.Bool("ids", false, "show stable mission IDs") - campaign := flags.String("campaign", "", "filter by campaign name") - track := flags.String("track", mission.TrackLinux, "filter by track: linux, docker, or all") - if help, err := parseFlags(flags, args); help || err != nil { - return err - } - if flags.NArg() > 0 { - return fmt.Errorf("list does not accept positional arguments") - } - if *completedOnly && *remainingOnly { - return fmt.Errorf("--completed and --remaining cannot be combined") - } - trackProvided := flagProvided(flags, "track") - selectedTrack := strings.ToLower(strings.TrimSpace(*track)) - if *campaign != "" && !trackProvided { - selectedTrack = "all" - } - if selectedTrack != mission.TrackLinux && selectedTrack != mission.TrackDocker && selectedTrack != "all" { - return fmt.Errorf("unknown track %q; use linux, docker, or all", *track) - } - matchesScope := func(item mission.Mission) bool { - if selectedTrack != "all" && item.EffectiveTrack() != selectedTrack { - return false - } - return *campaign == "" || strings.EqualFold(item.Campaign, *campaign) - } - heading := trackHeading(selectedTrack) - if strings.TrimSpace(*campaign) != "" { - heading = "WORLD · " + *campaign - } - fmt.Fprintln(a.out, a.style.Header(heading)) - shown := 0 - displayedTracks := make(map[string]bool) - tracks := []string{selectedTrack} - if selectedTrack == "all" { - tracks = []string{mission.TrackLinux, mission.TrackDocker} - } - printedWorld := false - for _, currentTrack := range tracks { - worlds := a.catalog.Worlds(currentTrack) - for _, world := range worlds { - visible := make([]mission.Mission, 0, len(world.Missions)) - for _, item := range world.Missions { - if !matchesScope(item) { - continue - } - complete := player.IsComplete(item.ID) - if *completedOnly && !complete || *remainingOnly && complete { - continue - } - visible = append(visible, item) - } - if len(visible) == 0 { - continue - } - displayedTracks[currentTrack] = true - if printedWorld { - fmt.Fprintln(a.out) - } - worldDone := completedMissions(world.Missions, player) - prefix := fmt.Sprintf("WORLD %d/%d", world.Number, len(worlds)) - if selectedTrack == "all" { - prefix = strings.ToUpper(trackDisplayName(currentTrack)) + " · " + prefix - } - fmt.Fprintf(a.out, "%s %s\n", a.style.World(prefix+" · "+world.Name), a.style.Muted(fmt.Sprintf("%d/%d complete", worldDone, len(world.Missions)))) - for _, item := range visible { - isComplete := player.IsComplete(item.ID) - status := "○" - if isComplete { - status = a.style.Success("✓") - } else { - status = a.style.Muted(status) - } - placement, _ := a.catalog.Placement(item.ID) - difficulty := a.style.Difficulty(item.Difficulty) - reward := a.style.Reward(fmt.Sprintf("%d XP", item.Rewards.XP)) - fmt.Fprintf(a.out, " %s Stage %d/%d · #%02d · %s · %s · %s", status, placement.StageNumber, placement.StageTotal, item.Number, item.Title, difficulty, reward) - if *showIDs { - fmt.Fprintf(a.out, " · %s", a.style.Muted(item.ID)) - } - fmt.Fprintln(a.out) - shown++ - } - printedWorld = true - } - } - if shown == 0 { - fmt.Fprintln(a.out, " No missions match these filters.") - } - scoped := slices.DeleteFunc(a.catalog.All(), func(item mission.Mission) bool { return !matchesScope(item) }) - completed, total := completedMissions(scoped, player), len(scoped) - fmt.Fprintf(a.out, "\n%s\n", a.style.Accent(fmt.Sprintf("%d/%d missions complete · Player total: %d XP", completed, total, player.XP))) - if shown > 0 { - if inMission { - fmt.Fprintf(a.out, "Navigate here: %s · %s · reveal IDs with %s\n", - a.style.Accent("world N"), a.style.Accent("play STAGE/ID"), a.style.Accent("list --ids")) - } else { - commandTrack := selectedTrack - if commandTrack == "all" && len(displayedTracks) == 1 { - for track := range displayedTracks { - commandTrack = track - } - } - follow := "opsquest play" - jump := "opsquest play --world N" - ids := "opsquest map --ids" - switch commandTrack { - case mission.TrackDocker: - follow = "opsquest play --track docker" - jump = "opsquest play --track docker --world N" - ids = "opsquest map --track docker --ids" - case "all": - follow = "opsquest play --track TRACK" - jump = "opsquest play --track TRACK --world N" - ids = "opsquest map --track all --ids" - } - fmt.Fprintf(a.out, "Continue: %s · Jump: %s · IDs: %s\n", - a.style.Accent(follow), a.style.Accent(jump), a.style.Accent(ids)) - } - } - if selectedTrack == mission.TrackDocker { - if item, found := a.catalog.FirstInTrack(mission.TrackDocker); found { - availability := game.EnvironmentAvailability(a.ctx, a.factory, item) - if availability.Available { - fmt.Fprintln(a.out, a.style.Success("Docker labs ready.")) - } else { - fmt.Fprintln(a.out, a.style.Warning(availability.Detail)) - } - } - } - return nil -} - -func (a *App) runProfile(args []string) error { - flags := a.newFlagSet("profile", func() { a.printProfileUsage(a.errOut) }) - name := flags.String("name", "", "update the operator display name") - if help, err := parseFlags(flags, args); help || err != nil { - return err - } - if flags.NArg() > 0 { - return fmt.Errorf("profile does not accept positional arguments") - } - nameProvided := flagProvided(flags, "name") - player, err := a.loadPlayer() - if err != nil { - return err - } - if nameProvided { - if err := profile.ValidateName(*name); err != nil { - return err - } - player.Name = strings.TrimSpace(*name) - if err := a.store.Save(player); err != nil { - return err - } - fmt.Fprintln(a.out, a.style.Success("Profile name updated.")) - } - completed := completedMissions(a.catalog.All(), player) - type worldProgress struct { - name string - track string - number int - completed int - total int - } - progress := make([]worldProgress, 0) - for _, track := range []string{mission.TrackLinux, mission.TrackDocker} { - for _, world := range a.catalog.Worlds(track) { - item := worldProgress{name: world.Name, track: track, number: world.Number, completed: completedMissions(world.Missions, player), total: len(world.Missions)} - progress = append(progress, item) - } - } - linuxMissions, dockerMissions := a.catalog.InTrack(mission.TrackLinux), a.catalog.InTrack(mission.TrackDocker) - linuxCompleted, linuxTotal := completedMissions(linuxMissions, player), len(linuxMissions) - dockerCompleted, dockerTotal := completedMissions(dockerMissions, player), len(dockerMissions) - fmt.Fprintln(a.out, a.style.Header("PROFILE")) - fmt.Fprintln(a.out) - fmt.Fprintf(a.out, "%s %s\n", a.style.Accent("Operator:"), player.Name) - fmt.Fprintf(a.out, "%s %s\n", a.style.Accent("Rank:"), player.Rank()) - fmt.Fprintf(a.out, "%s %d · %s\n", a.style.Accent("Level:"), player.Level(), a.style.Reward(fmt.Sprintf("%d XP", player.XP))) - if nextRank, needed, exists := player.NextRank(); exists { - fmt.Fprintf(a.out, "%s %s in %s\n", a.style.Muted("Next rank:"), nextRank, a.style.Reward(fmt.Sprintf("%d XP", needed))) - } - fmt.Fprintln(a.out) - labelWidth := 0 - for _, world := range progress { - label := fmt.Sprintf("World %d · %s", world.number, world.name) - labelWidth = max(labelWidth, len(label)) - } - printWorlds := func(track string) { - for _, world := range progress { - if world.track != track { - continue - } - label := fmt.Sprintf("World %d · %s", world.number, world.name) - fmt.Fprintf(a.out, " %s %s %3d%%\n", a.style.World(fmt.Sprintf("%-*s", labelWidth, label)), styledProgressBar(a.style, world.completed, world.total, 10), percentage(world.completed, world.total)) - } - } - fmt.Fprintf(a.out, "%s %s %3d%%\n", a.style.Section("Linux"), styledProgressBar(a.style, linuxCompleted, linuxTotal, 20), percentage(linuxCompleted, linuxTotal)) - printWorlds(mission.TrackLinux) - dockerState := "readiness: run opsquest doctor" - fmt.Fprintf(a.out, "%s %s %3d%% %s\n", a.style.Section("Docker"), styledProgressBar(a.style, dockerCompleted, dockerTotal, 20), percentage(dockerCompleted, dockerTotal), dockerState) - printWorlds(mission.TrackDocker) - fmt.Fprintf(a.out, "%s\n\n", a.style.Muted(fmt.Sprintf("K8s %s locked", progressBar(0, 20, 20)))) - fmt.Fprintf(a.out, "Commands mastered: %d\n", len(player.Commands)) - fmt.Fprintf(a.out, "Missions completed: %d\n", completed) - completedHints, activeHints := player.HintsUsed(), player.ActiveHints() - fmt.Fprintf(a.out, "Hints used: %d (completed: %d · active: %d)\n", completedHints+activeHints, completedHints, activeHints) - fmt.Fprintf(a.out, "Achievements: %d/%d\n", player.AchievementCount(), len(profile.AchievementDefinitions())) - return nil -} - -func (a *App) runCommands(args []string) error { - flags := a.newFlagSet("commands", func() { fmt.Fprintln(a.errOut, "Usage: opsquest commands") }) - if help, err := parseFlags(flags, args); help || err != nil { - return err - } - if flags.NArg() > 0 { - return fmt.Errorf("commands does not accept positional arguments") - } - player, err := a.loadPlayer() - if err != nil { - return err - } - commands := player.MasteredCommands() - if len(commands) == 0 { - fmt.Fprintln(a.out, "No commands mastered yet. Start with 'opsquest play'.") - return nil - } - fmt.Fprintf(a.out, "%s\n\n", a.style.Header(fmt.Sprintf("COMMAND MASTERY (%d)", len(commands)))) - for _, command := range commands { - name := fmt.Sprintf("%-12s", command) - fmt.Fprintf(a.out, " %s used successfully %d %s\n", a.style.Accent(name), player.Commands[command], plural(player.Commands[command], "time", "times")) - } - return nil -} - -func (a *App) runAchievements(args []string) error { - flags := a.newFlagSet("achievements", func() { fmt.Fprintln(a.errOut, "Usage: opsquest achievements") }) - if help, err := parseFlags(flags, args); help || err != nil { - return err - } - if flags.NArg() > 0 { - return fmt.Errorf("achievements does not accept positional arguments") - } - player, err := a.loadPlayer() - if err != nil { - return err - } - definitions := profile.AchievementDefinitions() - fmt.Fprintln(a.out, a.style.Header("ACHIEVEMENTS")) - for _, achievement := range definitions { - unlockedAt, unlocked := player.Unlocked[achievement.ID] - if unlocked { - title := fmt.Sprintf("%-22s", achievement.Title) - fmt.Fprintf(a.out, " %s %s %s %s\n", a.style.Achievement("★"), a.style.Achievement(title), achievement.Description, a.style.Muted("["+unlockedAt.Local().Format("2006-01-02")+"]")) - } else { - fmt.Fprintf(a.out, " %s %-22s %s\n", a.style.Muted("☆"), achievement.Title, achievement.Description) - } - } - fmt.Fprintf(a.out, "\n%d/%d unlocked\n", player.AchievementCount(), len(definitions)) - return nil -} - -func (a *App) runShow(args []string) error { - flags := a.newFlagSet("show", func() { fmt.Fprintln(a.errOut, "Usage: opsquest show [MISSION]") }) - if help, err := parseFlags(flags, args); help || err != nil { - return err - } - if flags.NArg() > 1 { - return fmt.Errorf("usage: opsquest show [MISSION]") - } - player, err := a.loadPlayer() - if err != nil { - return err - } - var item mission.Mission - var found bool - if flags.NArg() == 1 { - item, found = a.catalog.Find(flags.Arg(0)) - if !found { - return fmt.Errorf("mission %q not found; run 'opsquest list'", flags.Arg(0)) - } - } else { - item, found = a.catalog.NextInTrack(mission.TrackLinux, player.IsComplete) - if !found { - item, found = a.catalog.LastInTrack(mission.TrackLinux) - } - } - if !found { - return fmt.Errorf("no missions are available") - } - completion, complete := player.Completed[item.ID] - status := "not completed" - if complete { - status = "completed" - } - hintsUsed := player.MissionHints(item.ID) - fmt.Fprintln(a.out, a.style.Header(fmt.Sprintf("MISSION %02d: %s", item.Number, item.Title))) - if placement, exists := a.catalog.Placement(item.ID); exists { - fmt.Fprintf(a.out, "Track: %s · World %d/%d: %s · Stage %d/%d\n", - trackDisplayName(placement.Track), placement.WorldNumber, placement.WorldTotal, - a.style.World(placement.WorldName), placement.StageNumber, placement.StageTotal) - } else { - fmt.Fprintf(a.out, "Track: %s · Campaign: %s\n", trackDisplayName(item.EffectiveTrack()), a.style.World(item.Campaign)) - } - reward := a.style.Reward(fmt.Sprintf("%d XP", game.AdjustedReward(item, hintsUsed))) - if complete { - reward = a.style.Accent(fmt.Sprintf("claimed · %d XP earned", completion.XP)) - } - fmt.Fprintf(a.out, "Difficulty: %s · Reward: %s\n", a.style.Difficulty(item.Difficulty), reward) - styledStatus := a.style.Accent(status) - if complete { - styledStatus = a.style.Success(status) - } - remainingHints := max(len(item.Hints)-hintsUsed, 0) - hintStatus := fmt.Sprintf("%d used · %d remaining · %d total", hintsUsed, remainingHints, len(item.Hints)) - if complete { - hintStatus = fmt.Sprintf("%d used on first completion · replay hints do not change XP", completion.HintsUsed) - } - fmt.Fprintf(a.out, "Status: %s · Outcome checks: %d · Hints: %s\n\n", styledStatus, len(item.Validation.All), hintStatus) - fmt.Fprintf(a.out, "%s\n%s\n\n", a.style.Section("INCIDENT"), item.Story) - fmt.Fprintf(a.out, "%s\n%s\n\n%s\n", a.style.Section("OBJECTIVE"), item.Objective, a.style.CommandGuide(item.SuggestedCommands)) - if item.EffectiveEnvironment() == mission.EnvironmentDocker { - availability := game.EnvironmentAvailability(a.ctx, a.factory, item) - fmt.Fprintln(a.out) - if availability.Available { - fmt.Fprintln(a.out, a.style.Success("Docker lab ready.")) - } else { - fmt.Fprintln(a.out, a.style.Warning(availability.Detail)) - } - } - fmt.Fprintf(a.out, "\nPlay with: opsquest play %d\n", item.Number) - return nil -} - -func (a *App) runDoctor(args []string) error { - flags := a.newFlagSet("doctor", func() { fmt.Fprintln(a.errOut, "Usage: opsquest doctor [--cleanup]") }) - cleanup := flags.Bool("cleanup", false, "remove Docker lab containers left behind by exited OpsQuest processes") - if help, err := parseFlags(flags, args); help || err != nil { - return err - } - if flags.NArg() > 0 { - return fmt.Errorf("doctor does not accept positional arguments") - } - player, err := a.loadPlayer() - if err != nil { - return fmt.Errorf("profile check failed: %w", err) - } - fmt.Fprintln(a.out, a.style.Header("OpsQuest diagnostics")) - check := a.style.Success("✓") - fmt.Fprintf(a.out, " %s embedded catalog: %d missions (%d Linux, %d Docker)\n", check, len(a.catalog.All()), len(a.catalog.InTrack(mission.TrackLinux)), len(a.catalog.InTrack(mission.TrackDocker))) - fmt.Fprintf(a.out, " %s profile: version %d, %d completed missions\n", check, player.Version, len(player.Completed)) - fmt.Fprintf(a.out, " %s profile path: %s\n", check, a.store.Path()) - fmt.Fprintf(a.out, " %s Linux labs: in-memory; no host shell or filesystem access\n", check) - dockerReady := false - if item, found := a.catalog.FirstInTrack(mission.TrackDocker); found { - availability := game.EnvironmentAvailability(a.ctx, a.factory, item) - dockerReady = availability.Available - if availability.Available { - fmt.Fprintf(a.out, " %s docker labs: ready · %s\n", check, availability.Detail) - } else { - fmt.Fprintf(a.out, " %s docker labs: unavailable · %s\n", a.style.Warning("!"), availability.Detail) - } - } - return a.reportOrphanedLabs(*cleanup, dockerReady) -} - -// reportOrphanedLabs checks for, or with cleanup removes, lab resources left -// behind by OpsQuest processes that exited without closing their attempt. -// A plain doctor run stays read-only and only checks a reachable engine. -func (a *App) reportOrphanedLabs(cleanup, dockerReady bool) error { - janitor, supported := a.factory.(game.ResourceJanitor) - check := a.style.Success("✓") - warning := a.style.Warning("!") - if !supported { - if cleanup { - fmt.Fprintf(a.out, " %s docker cleanup: nothing to clean; Docker labs are not configured\n", check) - } - return nil - } - if cleanup { - removed, err := janitor.RemoveOrphanedResources(a.ctx) - if err != nil { - if removed > 0 { - fmt.Fprintf(a.out, " %s docker cleanup: removed %d, then failed · %v\n", warning, removed, err) - } else { - fmt.Fprintf(a.out, " %s docker cleanup: failed · %v\n", warning, err) - } - return fmt.Errorf("docker cleanup failed: %w", err) - } - fmt.Fprintf(a.out, " %s docker cleanup: removed %d orphaned lab %s\n", check, removed, plural(removed, "resource", "resources")) - return nil - } - if !dockerReady { - return nil - } - count, err := janitor.OrphanedResources(a.ctx) - switch { - case err != nil: - fmt.Fprintf(a.out, " %s docker cleanup: could not check for orphaned lab resources · %v\n", warning, err) - case count > 0: - fmt.Fprintf(a.out, " %s docker cleanup: %d orphaned lab %s from exited OpsQuest processes; run 'opsquest doctor --cleanup'\n", warning, count, plural(count, "resource", "resources")) - default: - fmt.Fprintf(a.out, " %s docker cleanup: no orphaned lab resources\n", check) - } - return nil -} - -func (a *App) runReset(args []string) error { - flags := a.newFlagSet("reset", func() { fmt.Fprintln(a.errOut, "Usage: opsquest reset [--yes]") }) - yes := flags.Bool("yes", false, "reset without confirmation") - if help, err := parseFlags(flags, args); help || err != nil { - return err - } - if flags.NArg() > 0 { - return fmt.Errorf("reset does not accept positional arguments") - } - if !*yes { - fmt.Fprint(a.out, "Reset all OpsQuest progress? [y/N] ") - reader := bufio.NewReader(a.in) - answer, err := reader.ReadString('\n') - if err != nil && err != io.EOF { - return fmt.Errorf("read confirmation: %w", err) - } - answer = strings.ToLower(strings.TrimSpace(answer)) - if answer != "y" && answer != "yes" { - fmt.Fprintln(a.out, "Reset cancelled.") - return nil - } - } - removed, err := a.store.Reset() - if err != nil { - return err - } - if removed { - fmt.Fprintln(a.out, a.style.Success("Progress reset. Welcome back, Intern.")) - } else { - fmt.Fprintln(a.out, a.style.Muted("No saved progress found.")) - } - return nil -} - -func (a *App) runHelp(args []string) error { - if len(args) == 0 || len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { - a.printUsage() - return nil - } - if len(args) > 1 { - return fmt.Errorf("usage: opsquest help [COMMAND]") - } - switch args[0] { - case "play": - a.printPlayUsage(a.out) - case "guide", "tutorial": - fmt.Fprintln(a.out, "Usage: opsquest guide") - case "list", "campaign", "map", "worlds": - a.printListUsage(a.out) - case "profile": - a.printProfileUsage(a.out) - case "commands": - fmt.Fprintln(a.out, "Usage: opsquest commands") - case "achievements": - fmt.Fprintln(a.out, "Usage: opsquest achievements") - case "show", "mission": - fmt.Fprintln(a.out, "Usage: opsquest show [MISSION]") - case "doctor": - fmt.Fprintln(a.out, "Usage: opsquest doctor [--cleanup]") - case "reset": - fmt.Fprintln(a.out, "Usage: opsquest reset [--yes]") - case "version": - fmt.Fprintln(a.out, "Usage: opsquest version") - default: - return fmt.Errorf("unknown command %q", args[0]) - } - return nil -} - -func (a *App) printPlayUsage(out io.Writer) { - fmt.Fprintln(out, "Usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [--web] [MISSION]") - fmt.Fprintln(out, "Without a selector, resume the first incomplete mission and continue the recommended path.") - fmt.Fprintln(out, "A mission number or ID follows catalog order from that exact stage; --world stays inside one world.") - fmt.Fprintln(out, "Use --once to return after one completed mission.") - fmt.Fprintln(out, "Use --web to show guidance and live objective progress in a local browser companion.") -} - -func (a *App) printListUsage(out io.Writer) { - fmt.Fprintln(out, "Usage: opsquest map|list [--completed|--remaining] [--ids] [--campaign NAME] [--track linux|docker|all]") -} - -func (a *App) printProfileUsage(out io.Writer) { - fmt.Fprintln(out, "Usage: opsquest profile [--name NAME]") -} - -func (a *App) printUsage() { - fmt.Fprintln(a.out, a.style.Header("OpsQuest")+" — learn operations by fixing fictional production") - fmt.Fprintln(a.out) - fmt.Fprintln(a.out, `Usage: - opsquest play [OPTIONS] Follow the recommended path, one world, or one mission - opsquest guide Replay the getting-started guide - opsquest map View worlds, stages, and completion progress - opsquest list Alias for map; accepts progress filters - opsquest profile Show rank, XP, and world progress - opsquest commands Show commands practiced successfully - opsquest achievements Show learning achievements - opsquest show [MISSION] Preview a mission without starting it - opsquest doctor Check the catalog, profile, safety mode, and lab cleanup - opsquest reset [--yes] Reset local progress - opsquest version Print the version - -New here: opsquest guide -Start playing: opsquest play`) -} - -func progressBar(value, total, width int) string { - filled := progressFilled(value, total, width) - return strings.Repeat("█", filled) + strings.Repeat("░", width-filled) -} - -func styledProgressBar(style ui.Style, value, total, width int) string { - filled := progressFilled(value, total, width) - return style.Progress(strings.Repeat("█", filled), strings.Repeat("░", width-filled)) -} - -func progressFilled(value, total, width int) int { - filled := 0 - if total > 0 { - filled = value * width / total - } - return min(max(filled, 0), width) -} - -func percentage(value, total int) int { - if total == 0 { - return 0 - } - return value * 100 / total -} - -func plural(value int, singular, plural string) string { - if value == 1 { - return singular - } - return plural -} - -func hasFlag(args []string, name string) bool { - for _, arg := range args { - if arg == name || strings.HasPrefix(arg, name+"=") { - return true - } - } - return false -} - -func completedMissions(items []mission.Mission, player profile.Profile) int { - completed := 0 - for _, item := range items { - if player.IsComplete(item.ID) { - completed++ - } - } - return completed -} - -func trackDisplayName(track string) string { - switch track { - case mission.TrackDocker: - return "Docker" - case mission.TrackLinux: - return "Linux" - default: - return track - } -} - -func trackHeading(track string) string { - switch track { - case mission.TrackDocker: - return "DOCKER LABS" - case "all": - return "ALL MISSIONS" - default: - return "LINUX CAMPAIGN" - } -} diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index c5dea72..f5698d2 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -3,80 +3,16 @@ package cli import ( "bytes" "context" - "fmt" "path/filepath" - "regexp" "strings" "testing" - "time" "github.com/aleksandergregersen/opsquest/internal/buildinfo" "github.com/aleksandergregersen/opsquest/internal/game" - "github.com/aleksandergregersen/opsquest/internal/mission" "github.com/aleksandergregersen/opsquest/internal/profile" "github.com/aleksandergregersen/opsquest/internal/ui" ) -var sgrPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -var linuxMissionIDsThroughArchive = []string{ - "linux-orientation", - "linux-config-crawl", - "linux-read-handoff", - "linux-workspace", - "linux-find-logs", - "linux-release-shuffle", - "linux-permissions", - "linux-environment", - "linux-log-preview", - "linux-runaway", - "linux-archive-rescue", -} - -func testApp(t *testing.T, input string, store profile.Store) (*App, *bytes.Buffer, *bytes.Buffer) { - t.Helper() - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - out, errOut := &bytes.Buffer{}, &bytes.Buffer{} - return New(Config{ - In: strings.NewReader(input), - Out: out, - ErrOut: errOut, - Catalog: catalog, - Store: store, - }), out, errOut -} - -type testWebCompanion struct { - events []game.AttemptEvent - closeCount int - closeErr error -} - -func (c *testWebCompanion) ReportAttempt(event game.AttemptEvent) { - c.events = append(c.events, game.CloneAttemptEvent(event)) -} - -func (c *testWebCompanion) URL() string { return "http://127.0.0.1:43210/pair?token=test" } - -func (c *testWebCompanion) Close(context.Context) error { - c.closeCount++ - return c.closeErr -} - -func seedCompletedMissions(t *testing.T, store profile.Store, missionIDs ...string) { - t.Helper() - player := profile.New("alex") - for _, id := range missionIDs { - player.Complete(id, 0, 0, time.Unix(1, 0)) - } - if err := store.Save(player); err != nil { - t.Fatal(err) - } -} - func TestNewUsesSafeContextAndFactoryDefaults(t *testing.T) { store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") app, _, _ := testApp(t, "", store) @@ -147,913 +83,6 @@ func TestForcedColorPreservesCLIText(t *testing.T) { } } -func TestPlayAwardsHintAdjustedXPAndPersistsProgress(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "hint\npwd\n", store) - if err := app.Run([]string{"play", "01"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "✓ Mission complete!") || !strings.Contains(out.String(), "+30 XP") { - t.Fatalf("output did not show adjusted completion:\n%s", out.String()) - } - player, err := store.Load() - if err != nil { - t.Fatal(err) - } - if player.XP != 30 || !player.IsComplete("linux-orientation") || player.Commands["pwd"] != 1 { - t.Fatalf("profile = %#v", player) - } -} - -func TestWebPlayUsesCompanionForMissionPresentationAndLiveProgress(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "hint\npwd\n", store) - companion := &testWebCompanion{} - app.startCompanion = func(context.Context) (Companion, error) { return companion, nil } - - if err := app.Run([]string{"play", "--web", "--once", "1"}); err != nil { - t.Fatalf("web play error = %v; stderr = %s", err, errOut.String()) - } - if companion.closeCount != 1 { - t.Fatalf("companion close count = %d, want 1", companion.closeCount) - } - if len(companion.events) != 4 || companion.events[0].Type != game.AttemptStarted || companion.events[1].Type != game.AttemptHint || companion.events[3].Type != game.AttemptCompleted { - t.Fatalf("companion event types = %#v", eventTypesForCLI(companion.events)) - } - output := out.String() - for _, visible := range []string{ - "WEB MISSION COMPANION", - companion.URL(), - "Keep this terminal for commands", - "Mission 01 ready in the web companion", - "Hint 1/2 revealed in the web companion", - "/home/operator", - "✓ Mission complete!", - } { - if !strings.Contains(output, visible) { - t.Errorf("web play output missing %q:\n%s", visible, output) - } - } - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("1") - for _, hidden := range []string{item.Story, item.Objective, item.Hints[0], item.Explanation, "FIRST SHIFT BRIEFING", "Commands you may need to solve this level:"} { - if strings.Contains(output, hidden) { - t.Errorf("web play terminal leaked companion content %q:\n%s", hidden, output) - } - } - player, err := store.Load() - if err != nil { - t.Fatal(err) - } - if player.XP != 30 || !player.IsComplete(item.ID) || !player.Onboarded { - t.Fatalf("web play profile = %#v", player) - } -} - -func TestWebPlayFailsBeforeOnboardingWhenCompanionCannotStart(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, _, _ := testApp(t, "", store) - app.startCompanion = func(context.Context) (Companion, error) { - return nil, fmt.Errorf("loopback unavailable") - } - - err := app.Run([]string{"play", "--web", "1"}) - if err == nil || !strings.Contains(err.Error(), "start web companion: loopback unavailable") { - t.Fatalf("web companion start error = %v", err) - } - player, loadErr := store.Load() - if loadErr != nil { - t.Fatal(loadErr) - } - if player.Onboarded || len(player.Completed) != 0 || player.XP != 0 { - t.Fatalf("failed companion start changed profile = %#v", player) - } -} - -func TestWebPlayReportsCompanionCloseFailureAfterPersistingCompletion(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, _, _ := testApp(t, "pwd\n", store) - companion := &testWebCompanion{closeErr: fmt.Errorf("shutdown stalled")} - app.startCompanion = func(context.Context) (Companion, error) { return companion, nil } - - err := app.Run([]string{"play", "--web", "--once", "1"}) - if err == nil || !strings.Contains(err.Error(), "close web companion: shutdown stalled") { - t.Fatalf("web companion close error = %v", err) - } - player, loadErr := store.Load() - if loadErr != nil { - t.Fatal(loadErr) - } - if !player.IsComplete("linux-orientation") || player.XP != 40 { - t.Fatalf("completion was not persisted before companion close error: %#v", player) - } -} - -func eventTypesForCLI(events []game.AttemptEvent) []game.AttemptEventType { - types := make([]game.AttemptEventType, len(events)) - for index, event := range events { - types[index] = event.Type - } - return types -} - -func TestReplayDoesNotAwardXPAgain(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - first, _, _ := testApp(t, "pwd\n", store) - if err := first.Run([]string{"play", "1"}); err != nil { - t.Fatal(err) - } - second, out, _ := testApp(t, "pwd\n", store) - if err := second.Run([]string{"play", "linux-orientation"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "XP was already claimed") { - t.Fatalf("replay output = %s", out.String()) - } - if !strings.Contains(out.String(), "Reward: already claimed · 40 XP base") { - t.Fatalf("replay mission advertised claimable XP:\n%s", out.String()) - } - player, _ := store.Load() - if player.XP != 40 { - t.Fatalf("XP after replay = %d, want 40", player.XP) - } - preview, previewOut, _ := testApp(t, "", store) - if err := preview.Run([]string{"show", "1"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(previewOut.String(), "Reward: claimed · 40 XP earned") || !strings.Contains(previewOut.String(), "Hints: 0 used on first completion") { - t.Fatalf("completed preview advertised a fresh reward or hint budget:\n%s", previewOut.String()) - } -} - -func TestBarePlayContinuesThroughIncompleteMissions(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - input := strings.Join([]string{ - "pwd", - "cd /srv/web/config/live", - "quit", - }, "\n") + "\n" - app, out, errOut := testApp(t, input, store) - if err := app.Run([]string{"play"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - - output := out.String() - for _, expected := range []string{ - "✓ Mission complete!", - "Continuing to Mission 02: Configuration Crawl", - "Continuing to Mission 03: Read the Handoff", - "Mission paused", - } { - if !strings.Contains(output, expected) { - t.Errorf("continuous play output missing %q:\n%s", expected, output) - } - } - player, err := store.Load() - if err != nil { - t.Fatal(err) - } - if !player.IsComplete("linux-orientation") || !player.IsComplete("linux-config-crawl") { - t.Fatalf("completed missions = %#v", player.Completed) - } - if player.IsComplete("linux-read-handoff") || player.XP != 85 { - t.Fatalf("player after continuous play = %#v", player) - } -} - -func TestSelectedMissionContinuesAfterCompletion(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "pwd\nquit\n", store) - if err := app.Run([]string{"play", "1"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{"Continuing to Mission 02: Configuration Crawl", "MISSION 02", "Mission paused"} { - if !strings.Contains(out.String(), expected) { - t.Fatalf("selected mission did not continue with %q:\n%s", expected, out.String()) - } - } - if strings.Contains(out.String(), "NEXT RECOMMENDED") { - t.Fatalf("continuous mission unexpectedly returned a one-shot recommendation:\n%s", out.String()) - } -} - -func TestSelectedMissionReplayContinuesWithoutRepeatingWorldCompletion(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - seedCompletedMissions(t, store, linuxMissionIDsThroughArchive...) - - app, out, errOut := testApp(t, "pwd\nquit\n", store) - if err := app.Run([]string{"play", "1"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{"Replay complete", "Continuing to Mission 02: Configuration Crawl", "MISSION 02"} { - if !strings.Contains(out.String(), expected) { - t.Fatalf("selected replay did not continue with %q:\n%s", expected, out.String()) - } - } - if strings.Contains(out.String(), "World 1 complete") { - t.Fatalf("replay repeated an already-earned world completion:\n%s", out.String()) - } - if strings.Contains(out.String(), "Continuing to Mission 12") { - t.Fatalf("selected replay snapped back to global progress:\n%s", out.String()) - } -} - -func TestInMissionIDJumpFollowsTheSelectedSequence(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - seedCompletedMissions(t, store, linuxMissionIDsThroughArchive...) - - app, out, errOut := testApp(t, "play linux-orientation\npwd\nquit\n", store) - if err := app.Run([]string{"play", "10"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - output := out.String() - switchIndex := strings.Index(output, "Switching to Mission 01") - if switchIndex < 0 { - t.Fatalf("mission jump was not shown:\n%s", output) - } - afterJump := output[switchIndex:] - for _, expected := range []string{"MISSION 01: Where Am I?", "Replay complete", "Continuing to Mission 02: Configuration Crawl", "MISSION 02"} { - if !strings.Contains(afterJump, expected) { - t.Fatalf("selected sequence missing %q:\n%s", expected, afterJump) - } - } - if strings.Contains(afterJump, "Continuing to Mission 12") { - t.Fatalf("in-mission jump snapped back to global progress:\n%s", afterJump) - } -} - -func TestPlayOnceReturnsAfterSelectedMission(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "pwd\n", store) - if err := app.Run([]string{"play", "--once", "1"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - if strings.Contains(out.String(), "MISSION 02") { - t.Fatalf("--once unexpectedly continued:\n%s", out.String()) - } - if !strings.Contains(out.String(), "NEXT RECOMMENDED") || !strings.Contains(out.String(), "Mission 02: Configuration Crawl") { - t.Fatalf("--once did not explain the next step:\n%s", out.String()) - } -} - -func TestFreshPlayExplainsTheGameAndGuideIsReplayable(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "pwd\n", store) - if err := app.Run([]string{"play", "1"}); err != nil { - t.Fatalf("fresh play error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{ - "WELCOME TO OPSQUEST", - "final outcome is what counts", - "Hints are always available", - "opsquest map", - "never reach your host shell or files", - } { - if !strings.Contains(out.String(), expected) { - t.Errorf("fresh guide missing %q:\n%s", expected, out.String()) - } - } - - app, out, errOut = testApp(t, "quit\n", store) - if err := app.Run([]string{"play", "2"}); err != nil { - t.Fatalf("returning play error = %v; stderr = %s", err, errOut.String()) - } - if strings.Contains(out.String(), "WELCOME TO OPSQUEST") { - t.Fatalf("first-activity guide repeated after progress:\n%s", out.String()) - } - - app, out, errOut = testApp(t, "", store) - if err := app.Run([]string{"guide"}); err != nil { - t.Fatalf("guide command error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "WELCOME TO OPSQUEST") { - t.Fatalf("guide command did not replay onboarding:\n%s", out.String()) - } -} - -func TestFirstRunGuideIsRememberedAfterImmediateQuit(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "quit\n", store) - if err := app.Run([]string{"play", "1"}); err != nil { - t.Fatalf("first play error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "WELCOME TO OPSQUEST") { - t.Fatalf("first play omitted quick start:\n%s", out.String()) - } - player, err := store.Load() - if err != nil { - t.Fatal(err) - } - if !player.Onboarded { - t.Fatal("first-play onboarding marker was not persisted") - } - - app, out, errOut = testApp(t, "quit\n", store) - if err := app.Run([]string{"play", "1"}); err != nil { - t.Fatalf("second play error = %v; stderr = %s", err, errOut.String()) - } - if strings.Contains(out.String(), "WELCOME TO OPSQUEST") { - t.Fatalf("quick start repeated after an immediate quit:\n%s", out.String()) - } -} - -func TestMapShowsTrackLocalWorldsAndStages(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "", store) - if err := app.Run([]string{"map"}); err != nil { - t.Fatalf("map error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{ - "WORLD 1/4 · First Day", - "Stage 6/6 · #06", - "WORLD 2/4 · The Logpocalypse", - "WORLD 4/4 · The Automation Shift", - "Jump: opsquest play --world N", - } { - if !strings.Contains(out.String(), expected) { - t.Errorf("map output missing %q:\n%s", expected, out.String()) - } - } - if strings.Contains(out.String(), "Container Census") { - t.Fatalf("default Linux map included the optional Docker track:\n%s", out.String()) - } -} - -func TestPlayCanJumpToAndCompleteOneWorld(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "quit\n", store) - if err := app.Run([]string{"play", "--world", "2"}); err != nil { - t.Fatalf("world jump error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "MISSION 07: Permission to Deploy") || !strings.Contains(out.String(), "World 2/4") { - t.Fatalf("world jump started the wrong stage:\n%s", out.String()) - } - - commands := strings.Join([]string{ - "pwd", - "cd /srv/web/config/live", - "cat handoff.txt", - "mkdir -p reports/daily", - "touch reports/daily/summary.txt", - `find . -name "*.log" -exec grep -l "ERROR" {} \;`, - "mv incident-104.txt /archive/2026/incident-104.txt", - }, "\n") + "\n" - app, out, errOut = testApp(t, commands, profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex")) - if err := app.Run([]string{"play", "--world", "1"}); err != nil { - t.Fatalf("world play error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "World 1 complete: First Day!") || strings.Contains(out.String(), "MISSION 07") { - t.Fatalf("world play did not stop at its boundary:\n%s", out.String()) - } - - replayStore := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - seedCompletedMissions(t, replayStore, linuxMissionIDsThroughArchive[:6]...) - app, out, errOut = testApp(t, commands, replayStore) - if err := app.Run([]string{"play", "--world", "1"}); err != nil { - t.Fatalf("completed world replay error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{ - "World 1 is complete; replaying Stage 1", - "MISSION 02: Configuration Crawl", - "MISSION 03: Read the Handoff", - "MISSION 04: A Place for Everything", - "MISSION 05: The Missing Log File", - "MISSION 06: The Release Shuffle", - "World 1 complete: First Day!", - } { - if !strings.Contains(out.String(), expected) { - t.Fatalf("completed world replay missing %q:\n%s", expected, out.String()) - } - } - if strings.Contains(out.String(), "MISSION 07") { - t.Fatalf("completed world replay crossed its boundary:\n%s", out.String()) - } - - app, _, _ = testApp(t, "", store) - if err := app.Run([]string{"play", "--world", "99"}); err == nil || !strings.Contains(err.Error(), "world 99 does not exist") { - t.Fatalf("invalid world error = %v", err) - } - for _, test := range []struct { - args []string - want string - }{ - {args: []string{"play", "--world", "0"}, want: "world number must be positive"}, - {args: []string{"play", "--track", "mainframe"}, want: "use linux or docker"}, - {args: []string{"play", "--world", "1", "3"}, want: "MISSION cannot be combined"}, - } { - app, _, _ = testApp(t, "", store) - if err := app.Run(test.args); err == nil || !strings.Contains(err.Error(), test.want) { - t.Errorf("Run(%v) error = %v, want %q", test.args, err, test.want) - } - } -} - -func TestMissionPromptCanShowMapAndJumpWorlds(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "map\nworld 4\nquit\n", store) - if err := app.Run([]string{"play", "1"}); err != nil { - t.Fatalf("in-mission world navigation error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{ - "WORLD 4/4 · The Automation Shift", - "Switching to Mission 26: Run the Runbook", - "MISSION 26: Run the Runbook", - "Navigate here: world N", - } { - if !strings.Contains(out.String(), expected) { - t.Errorf("in-mission world navigation missing %q:\n%s", expected, out.String()) - } - } - if strings.Contains(errOut.String(), "command not available") { - t.Fatalf("world navigation reached the sandbox: %s", errOut.String()) - } -} - -func TestMissionPromptCanScopeTheCurrentStageToItsWorld(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - seedCompletedMissions(t, store, linuxMissionIDsThroughArchive[:5]...) - - app, out, errOut := testApp(t, "world 1\nmv incident-104.txt /archive/2026/incident-104.txt\n", store) - if err := app.Run([]string{"play"}); err != nil { - t.Fatalf("same-stage world selection error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{"World 1 route selected", "World 1 complete: First Day!"} { - if !strings.Contains(out.String(), expected) { - t.Fatalf("same-stage world selection missing %q:\n%s", expected, out.String()) - } - } - if strings.Contains(out.String(), "MISSION 07") { - t.Fatalf("same-stage world route crossed its boundary:\n%s", out.String()) - } -} - -func TestMissionPromptReportsInvalidWorldWithoutDispatchingIt(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, _, errOut := testApp(t, "world 0\nworld 99\nquit\n", store) - if err := app.Run([]string{"play", "1"}); err != nil { - t.Fatalf("play error = %v", err) - } - for _, expected := range []string{"world number must be a positive integer", "world 99 does not exist"} { - if !strings.Contains(errOut.String(), expected) { - t.Errorf("world navigation stderr missing %q: %s", expected, errOut.String()) - } - } - if strings.Contains(errOut.String(), "command not available") { - t.Fatalf("invalid world navigation reached the sandbox: %s", errOut.String()) - } -} - -func TestMissionPromptExplainsCompletedWorldReplay(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - seedCompletedMissions(t, store, linuxMissionIDsThroughArchive[:6]...) - app, out, errOut := testApp(t, "world 1\npwd\nquit\n", store) - if err := app.Run([]string{"play", "6"}); err != nil { - t.Fatalf("play error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "World 1 is complete; replaying Stage 1") || !strings.Contains(out.String(), "MISSION 01: Where Am I?") { - t.Fatalf("completed-world replay was not explained:\n%s", out.String()) - } - if !strings.Contains(out.String(), "Continuing to Mission 02: Configuration Crawl") || !strings.Contains(out.String(), "MISSION 02") { - t.Fatalf("completed-world replay did not retain its world route:\n%s", out.String()) - } -} - -func TestMissionPromptCanListAndSwitchMissions(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - seed, _, _ := testApp(t, "pwd\n", store) - if err := seed.Run([]string{"play", "1"}); err != nil { - t.Fatal(err) - } - - input := strings.Join([]string{ - "opsquest list --completed", - "opsquest play linux-workspace", - "mkdir -p reports/daily", - "list --completed", - "status", - "quit", - }, "\n") + "\n" - app, out, errOut := testApp(t, input, store) - if err := app.Run([]string{"play", "9"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - - output := out.String() - for _, expected := range []string{ - "LINUX CAMPAIGN", - "1/23 missions complete", - "Switching to Mission 04: A Place for Everything", - "MISSION 04: A Place for Everything", - "✓ Directory exists: /workspace/reports/daily", - "○ File exists: /workspace/reports/daily/summary.txt", - } { - if !strings.Contains(output, expected) { - t.Errorf("in-mission navigation output missing %q:\n%s", expected, output) - } - } - if strings.Contains(errOut.String(), "command not available") { - t.Fatalf("navigation reached the sandbox dispatcher: %s", errOut.String()) - } -} - -func TestMissionPromptUsesWorldLocalStageNumbers(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "play 4\nquit\n", store) - if err := app.Run([]string{"play", "7"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - - output := out.String() - for _, expected := range []string{ - "Navigate: map · world N · play STAGE/ID", - "Switching to Mission 10: The Runaway Worker", - "MISSION 10: The Runaway Worker", - "World 2/4: The Logpocalypse · Stage 4/6", - } { - if !strings.Contains(output, expected) { - t.Errorf("world-local stage navigation missing %q:\n%s", expected, output) - } - } - if strings.Contains(output, "MISSION 04: A Place for Everything") { - t.Fatalf("World 2 stage navigation fell back to the global Mission 04:\n%s", output) - } - if errOut.Len() != 0 { - t.Fatalf("world-local stage navigation stderr = %s", errOut.String()) - } -} - -func TestMissionPromptStageJumpContinuesIntoNextWorld(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - seedCompletedMissions(t, store, linuxMissionIDsThroughArchive[:6]...) - input := "play 6\nmv /incoming/incident-104.txt /archive/2026/incident-104.txt\nquit\n" - app, out, errOut := testApp(t, input, store) - if err := app.Run([]string{"play", "3"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - - output := out.String() - for _, expected := range []string{ - "Switching to Mission 06: The Release Shuffle", - "Replay complete", - "Continuing to Mission 07: Permission to Deploy", - "World 2/4: The Logpocalypse", - } { - if !strings.Contains(output, expected) { - t.Errorf("stage jump did not continue with %q:\n%s", expected, output) - } - } -} - -func TestMissionPromptHandlesCurrentAndOutOfRangeWorldStages(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "play 1\nplay 7\nquit\n", store) - if err := app.Run([]string{"play", "7"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - - if !strings.Contains(out.String(), "Already playing Mission 07: Permission to Deploy.") { - t.Fatalf("same-stage navigation was not explained:\n%s", out.String()) - } - if strings.Contains(out.String(), "Switching to Mission") { - t.Fatalf("same or invalid stage unexpectedly switched missions:\n%s", out.String()) - } - wantError := `stage "7" does not exist in World 2; choose a stage from 1 to 6 or use map` - if !strings.Contains(errOut.String(), wantError) { - t.Fatalf("out-of-range stage error missing %q: %s", wantError, errOut.String()) - } -} - -func TestMissionPromptParsesQuotedNavigationArguments(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - player := profile.New("alex") - player.RecordCommands([]string{"pwd"}) - if err := store.Save(player); err != nil { - t.Fatal(err) - } - app, out, errOut := testApp(t, "list --campaign \"First Day\"\nquit\n", store) - if err := app.Run([]string{"play", "3"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "Where Am I?") || !strings.Contains(out.String(), "0/6 missions complete") { - t.Fatalf("quoted campaign list output:\n%s", out.String()) - } - if strings.Contains(out.String(), "Production Friday") || errOut.Len() != 0 { - t.Fatalf("quoted campaign navigation leaked missions or failed; stdout:\n%s\nstderr:\n%s", out.String(), errOut.String()) - } -} - -func TestMissionPromptReportsMalformedNavigation(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "list --campaign \"First Day\nquit\n", store) - if err := app.Run([]string{"play", "3"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(errOut.String(), "invalid mission navigation: unterminated double quote") { - t.Fatalf("malformed navigation stderr = %q", errOut.String()) - } - if !strings.Contains(out.String(), "Mission paused") { - t.Fatalf("session did not continue after malformed navigation:\n%s", out.String()) - } -} - -func TestMissionPromptSupportsPreviousAndNext(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "previous\nnext\nquit\n", store) - if err := app.Run([]string{"play", "9"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{ - "Switching to Mission 08: Wrong Planet", - "Switching to Mission 09: Opening Lines", - } { - if !strings.Contains(out.String(), expected) { - t.Errorf("arrow-style navigation output missing %q:\n%s", expected, out.String()) - } - } -} - -func TestHintPenaltySurvivesAPausedAttempt(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - paused, _, _ := testApp(t, "hint\nquit\n", store) - if err := paused.Run([]string{"play", "1"}); err != nil { - t.Fatal(err) - } - player, err := store.Load() - if err != nil { - t.Fatal(err) - } - if player.MissionHints("linux-orientation") != 1 { - t.Fatalf("saved hint progress = %d, want 1", player.MissionHints("linux-orientation")) - } - preview, previewOut, previewErr := testApp(t, "", store) - if err := preview.Run([]string{"show", "1"}); err != nil { - t.Fatalf("show hinted mission error = %v; stderr = %s", err, previewErr.String()) - } - if !strings.Contains(previewOut.String(), "Reward: 30 XP") || !strings.Contains(previewOut.String(), "Hints: 1 used · 1 remaining · 2 total") { - t.Fatalf("show did not reflect persisted hint state:\n%s", previewOut.String()) - } - profileApp, profileOut, profileErr := testApp(t, "", store) - if err := profileApp.Run([]string{"profile"}); err != nil { - t.Fatalf("profile with active hint error = %v; stderr = %s", err, profileErr.String()) - } - if !strings.Contains(profileOut.String(), "Hints used: 1 (completed: 0 · active: 1)") { - t.Fatalf("profile hid active hint penalties:\n%s", profileOut.String()) - } - - resumed, out, _ := testApp(t, "pwd\n", store) - if err := resumed.Run([]string{"play", "1"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "+30 XP") { - t.Fatalf("resumed output did not retain hint penalty:\n%s", out.String()) - } - player, _ = store.Load() - if player.MissionHints("linux-orientation") != 0 { - t.Fatalf("hint progress was not cleared after completion") - } -} - -func TestHintReportsTheActualFloorAdjustedCost(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "hint\nhint\nhint\nhint\nhint\nquit\n", store) - if err := app.Run([]string{"play", "12"}); err != nil { - t.Fatalf("hinted play error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "Hint 5/5 (-14 XP):") { - t.Fatalf("last hint did not report the reward floor's actual cost:\n%s", out.String()) - } -} - -func TestReplayHintsAdvanceWithoutChangingProgress(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - player := profile.New("alex") - completedAt := time.Unix(1, 0) - player.Complete("linux-find-logs", 75, 0, completedAt) - if err := store.Save(player); err != nil { - t.Fatal(err) - } - - app, out, errOut := testApp(t, "hint\nhint\nhint\nhint\nquit\n", store) - if err := app.Run([]string{"play", "5"}); err != nil { - t.Fatalf("replay hints error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{ - "Hint 1/3 (no XP cost):", - "Hint 2/3 (no XP cost):", - "Hint 3/3 (no XP cost):", - "No more hints.", - } { - if count := strings.Count(out.String(), expected); count != 1 { - t.Fatalf("replay output contains %q %d times, want once:\n%s", expected, count, out.String()) - } - } - - loaded, err := store.Load() - if err != nil { - t.Fatal(err) - } - completion := loaded.Completed["linux-find-logs"] - if loaded.XP != 75 || completion.XP != 75 || !completion.CompletedAt.Equal(completedAt) { - t.Fatalf("replay hints changed completion progress: %#v", loaded) - } - if got := loaded.MissionHints("linux-find-logs"); got != 0 { - t.Fatalf("replay hint progress persisted as %d, want 0", got) - } -} - -func TestListAndProfileWorkWithoutExistingSave(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "newbie") - app, out, _ := testApp(t, "", store) - if err := app.Run([]string{"list"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "0/23 missions complete") { - t.Fatalf("list output = %s", out.String()) - } - - app, out, _ = testApp(t, "", store) - if err := app.Run([]string{"profile"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "Operator: newbie") || !strings.Contains(out.String(), "Rank: Intern") { - t.Fatalf("profile output = %s", out.String()) - } -} - -func TestProfileDefersDockerReadinessToDoctor(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "", store) - factory := &cliDockerFactory{available: true, detail: "test Docker engine ready"} - app.factory = factory - - if err := app.Run([]string{"profile"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - if factory.availabilityChecks != 0 { - t.Fatalf("profile availability checks = %d, want 0", factory.availabilityChecks) - } - if !strings.Contains(out.String(), "readiness: run opsquest doctor") { - t.Fatalf("profile did not direct readiness checks to doctor:\n%s", out.String()) - } -} - -func TestListDockerTrackAndRejectsUnknownTrack(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "", store) - app.factory = &cliDockerFactory{available: true, detail: "test Docker engine ready"} - if err := app.Run([]string{"list", "--track", "docker", "--ids"}); err != nil { - t.Fatalf("list Docker track error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{ - "DOCKER LABS", - "Container Census", - "Shift Handoff", - "docker-container-census", - "WORLD 2/3 · Container Triage", - "Postmortem Triage", - "WORLD 3/3 · Network Plumbing", - "Segmentation", - "0/16 missions complete", - "Continue: opsquest play --track docker", - "Jump: opsquest play --track docker --world N", - "IDs: opsquest map --track docker --ids", - "Docker labs ready.", - } { - if !strings.Contains(out.String(), expected) { - t.Errorf("Docker list output missing %q:\n%s", expected, out.String()) - } - } - if strings.Contains(out.String(), "Where Am I?") { - t.Fatalf("Docker list leaked a Linux mission:\n%s", out.String()) - } - if got := app.factory.(*cliDockerFactory).availabilityChecks; got != 1 { - t.Fatalf("Docker list availability checks = %d, want 1", got) - } - - app, _, _ = testApp(t, "", store) - if err := app.Run([]string{"list", "--track", "mainframe"}); err == nil || !strings.Contains(err.Error(), "use linux, docker, or all") { - t.Fatalf("unknown track error = %v", err) - } -} - -func TestShowDockerMissionReportsReadinessAndToolHints(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "", store) - app.factory = &cliDockerFactory{available: true, detail: "test Docker engine ready"} - if err := app.Run([]string{"show", "20"}); err != nil { - t.Fatalf("show Docker mission error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{ - "MISSION 20: Container Census", - "Track: Docker", - "Outcome checks: 3", - "Hints: 0 used · 3 remaining · 3 total", - "Commands you may need to solve this level:", - " docker", - "Docker lab ready.", - } { - if !strings.Contains(out.String(), expected) { - t.Errorf("Docker show output missing %q:\n%s", expected, out.String()) - } - } -} - -func TestFreshDockerPlayDoesNotShowLinuxOnboarding(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "quit\n", store) - app.factory = &cliDockerFactory{available: true, detail: "test Docker engine ready"} - if err := app.Run([]string{"play", "--once", "20"}); err != nil { - t.Fatalf("Docker play error = %v; stderr = %s", err, errOut.String()) - } - if strings.Contains(out.String(), "WELCOME TO OPSQUEST") || strings.Contains(out.String(), "Start with the suggested command under the objective") { - t.Fatalf("Docker play received Linux-specific onboarding:\n%s", out.String()) - } - player, err := store.Load() - if err != nil { - t.Fatal(err) - } - if player.Onboarded { - t.Fatal("skipping Linux onboarding for Docker marked it as viewed") - } -} - -func TestUnavailableDockerMissionIsActionableAndDoesNotComplete(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, _, _ := testApp(t, "", store) - app.factory = &cliDockerFactory{ - available: false, - detail: "Docker labs unavailable: install Docker and start the daemon", - } - err := app.Run([]string{"play", "20"}) - if err == nil || !strings.Contains(err.Error(), "install Docker and start the daemon") { - t.Fatalf("unavailable Docker play error = %v", err) - } - player, loadErr := store.Load() - if loadErr != nil { - t.Fatal(loadErr) - } - if player.IsComplete("docker-container-census") || player.XP != 0 || len(player.Commands) != 0 { - t.Fatalf("unavailable Docker play changed profile = %#v", player) - } - if got := app.factory.(*cliDockerFactory).availabilityChecks; got != 0 { - t.Fatalf("Docker play availability checks = %d, want Create to be the only readiness path", got) - } -} - -func TestDockerMissionToolHintsAndOutcomeCompletionPersist(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - input := strings.Join([]string{ - "hint", - "hint", - "hint", - "docker ps -a", - "docker start api", - }, "\n") + "\n" - app, out, errOut := testApp(t, input, store) - factory := &cliDockerFactory{available: true, detail: "test Docker engine ready"} - app.factory = factory - if err := app.Run([]string{"play", "--once", "20"}); err != nil { - t.Fatalf("play Docker mission error = %v; stderr = %s", err, errOut.String()) - } - for _, expected := range []string{ - "Hint 1/3 (-10 XP): Images are reusable templates", - "Hint 2/3 (-10 XP): Use docker ps -a", - "Hint 3/3 (-10 XP): Start the existing container with docker start api", - "Progress — 2/3 outcome checks satisfied", - "✓ Mission complete!", - "+20 XP", - "New command discovered: docker", - } { - if !strings.Contains(out.String(), expected) { - t.Errorf("Docker play output missing %q:\n%s", expected, out.String()) - } - } - if factory.created != 1 { - t.Fatalf("Docker environments created = %d, want 1", factory.created) - } - if factory.availabilityChecks != 0 { - t.Fatalf("Docker play availability checks = %d, want Create to be the only readiness path", factory.availabilityChecks) - } - - player, err := store.Load() - if err != nil { - t.Fatal(err) - } - completion, complete := player.Completed["docker-container-census"] - if !complete || player.XP != 20 || completion.XP != 20 || completion.HintsUsed != 3 { - t.Fatalf("Docker completion profile = %#v", player) - } - if got := player.Commands["docker"]; got != 2 { - t.Fatalf("docker command mastery count = %d, want 2", got) - } - if player.MissionHints("docker-container-census") != 0 { - t.Fatal("completed Docker mission retained in-progress hints") - } -} - func TestSubcommandHelpIsSuccessful(t *testing.T) { store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") app, _, errOut := testApp(t, "", store) @@ -1064,429 +93,3 @@ func TestSubcommandHelpIsSuccessful(t *testing.T) { t.Fatalf("help output = %s", errOut.String()) } } - -func TestProfileRenameShowAndDoctor(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, _ := testApp(t, "", store) - if err := app.Run([]string{"profile", "--name", "casey"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "Operator: casey") { - t.Fatalf("profile output = %s", out.String()) - } - - app, out, _ = testApp(t, "", store) - if err := app.Run([]string{"show", "19"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "Production Friday") || !strings.Contains(out.String(), "Reward: 160 XP") || - !strings.Contains(out.String(), "Commands you may need to solve this level:") || - !strings.Contains(out.String(), " tar, sed, vi, chmod, ps, kill") { - t.Fatalf("show output = %s", out.String()) - } - - app, out, _ = testApp(t, "", store) - if err := app.Run([]string{"doctor"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "39 missions (23 Linux, 16 Docker)") || !strings.Contains(out.String(), "Linux labs: in-memory; no host shell or filesystem access") { - t.Fatalf("doctor output = %s", out.String()) - } -} - -func TestProfileRenameRejectsTerminalControlsWithoutChangingTheSave(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - player := profile.New("alex") - if err := store.Save(player); err != nil { - t.Fatal(err) - } - app, _, _ := testApp(t, "", store) - err := app.Run([]string{"profile", "--name", "casey\x1b[2J"}) - if err == nil || !strings.Contains(err.Error(), "control or non-printable") { - t.Fatalf("control-bearing name error = %v", err) - } - loaded, loadErr := store.Load() - if loadErr != nil { - t.Fatal(loadErr) - } - if loaded.Name != "alex" { - t.Fatalf("rejected rename changed profile name to %q", loaded.Name) - } -} - -func TestPipelineAndBossAchievementsUnlock(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - command := "grep ERROR incidents.log | awk '{print $3}' | sort | uniq > /reports/error-services.txt\n" - app, out, errOut := testApp(t, command, store) - if err := app.Run([]string{"play", "12"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - player, err := store.Load() - if err != nil { - t.Fatal(err) - } - for _, id := range []string{"first-fix", "pipe-dream", "boss-slayer"} { - if !player.HasAchievement(id) { - t.Errorf("achievement %s was not unlocked", id) - } - } - if !strings.Contains(out.String(), "Achievement unlocked: Pipe Dream") { - t.Fatalf("completion output = %s", out.String()) - } - - app, out, _ = testApp(t, "", store) - if err := app.Run([]string{"achievements"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "3/6 unlocked") { - t.Fatalf("achievements output = %s", out.String()) - } -} - -func TestListFiltersMissions(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - play, _, _ := testApp(t, "pwd\n", store) - if err := play.Run([]string{"play", "1"}); err != nil { - t.Fatal(err) - } - app, out, _ := testApp(t, "", store) - if err := app.Run([]string{"list", "--completed"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "Where Am I?") || strings.Contains(out.String(), "Configuration Crawl") { - t.Fatalf("filtered list output = %s", out.String()) - } -} - -func TestListCampaignFilterUsesCampaignTotal(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - app, out, errOut := testApp(t, "", store) - if err := app.Run([]string{"list", "--campaign", "First Day"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "0/6 missions complete") { - t.Fatalf("campaign-filtered total missing:\n%s", out.String()) - } - if strings.Contains(out.String(), "0/39 missions complete") || strings.Contains(out.String(), "Production Friday") { - t.Fatalf("campaign filter used catalog-wide scope:\n%s", out.String()) - } -} - -func TestMissionStatusAndRestart(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - input := strings.Join([]string{ - "mkdir -p reports/daily", - "status", - "restart", - "status", - "mkdir -p reports/daily", - "touch reports/daily/summary.txt", - }, "\n") + "\n" - app, out, errOut := testApp(t, input, store) - if err := app.Run([]string{"play", "4"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "Outcome checks satisfied: 1/2") || !strings.Contains(out.String(), "Outcome checks satisfied: 0/2") { - t.Fatalf("status output = %s", out.String()) - } - if !strings.Contains(out.String(), "Mission environment restarted") || !strings.Contains(out.String(), "✓ Mission complete!") { - t.Fatalf("restart output = %s", out.String()) - } -} - -func TestMissionStatusExplainsWrongPathWithoutRequiringCommandOrder(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - input := strings.Join([]string{ - "mkdir -p /workspace/reports/daily", - "touch summary.txt", - "status", - "quit", - }, "\n") + "\n" - app, out, errOut := testApp(t, input, store) - if err := app.Run([]string{"play", "4"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - output := out.String() - for _, expected := range []string{ - "Progress — 1/2 outcome checks satisfied", - "✓ Directory exists: /workspace/reports/daily", - "○ File exists: /workspace/reports/daily/summary.txt", - } { - if !strings.Contains(output, expected) { - t.Errorf("status output missing %q:\n%s", expected, output) - } - } - player, err := store.Load() - if err != nil { - t.Fatal(err) - } - if player.IsComplete("linux-workspace") { - t.Fatal("wrong-path file unexpectedly completed the mission") - } -} - -func TestWorkspaceAcceptsChangingDirectoryBeforeCreatingFile(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - input := "mkdir -p reports/daily\ncd reports/daily\ntouch summary.txt\n" - app, out, errOut := testApp(t, input, store) - if err := app.Run([]string{"play", "4"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - if !strings.Contains(out.String(), "✓ Mission complete!") { - t.Fatalf("alternative solution output:\n%s", out.String()) - } -} - -func TestCompletionistRequiresEveryCurrentCatalogMission(t *testing.T) { - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - items := catalog.InTrack(mission.TrackLinux) - completedAt := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) - tests := []struct { - name string - seedFrom int - wantUnlock bool - }{ - {name: "unknown ID cannot replace missing mission", seedFrom: 2, wantUnlock: false}, - {name: "all current missions still unlock", seedFrom: 1, wantUnlock: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - player := profile.New("alex") - for _, item := range items[test.seedFrom:] { - player.Complete(item.ID, 0, 0, completedAt) - } - player.Complete("retired-linux-mission", 0, 0, completedAt) - if err := store.Save(player); err != nil { - t.Fatal(err) - } - - app, out, errOut := testApp(t, "pwd\n", store) - if err := app.Run([]string{"play", "1"}); err != nil { - t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) - } - player, err = store.Load() - if err != nil { - t.Fatal(err) - } - if got := player.HasAchievement("linux-completionist"); got != test.wantUnlock { - t.Fatalf("completionist unlocked = %v, want %v; completed = %#v", got, test.wantUnlock, player.Completed) - } - if announced := strings.Contains(out.String(), "Achievement unlocked: Linux Completionist"); announced != test.wantUnlock { - t.Fatalf("completionist announcement = %v, want %v; output:\n%s", announced, test.wantUnlock, out.String()) - } - }) - } -} - -type cliDockerFactory struct { - available bool - detail string - created int - availabilityChecks int -} - -func (f *cliDockerFactory) Availability(_ context.Context, item mission.Mission) game.Availability { - f.availabilityChecks++ - if item.EffectiveEnvironment() != mission.EnvironmentDocker { - return game.Availability{Available: true, Detail: "in-memory sandbox ready"} - } - return game.Availability{Available: f.available, Detail: f.detail} -} - -func (f *cliDockerFactory) Create(ctx context.Context, item mission.Mission) (game.Environment, error) { - if item.EffectiveEnvironment() != mission.EnvironmentDocker { - return (game.SandboxFactory{}).Create(ctx, item) - } - if !f.available { - return nil, fmt.Errorf("%s", f.detail) - } - f.created++ - return &cliDockerEnvironment{ - running: map[string]bool{ - "api": false, - "metrics": true, - }, - containerCount: 2, - }, nil -} - -type cliDockerEnvironment struct { - running map[string]bool - containerCount int - closed bool -} - -func (e *cliDockerEnvironment) PromptLabel() string { return "docker" } - -func (e *cliDockerEnvironment) CompletionSource() game.CompletionSource { return nil } - -func (e *cliDockerEnvironment) Execute(ctx context.Context, line string) (game.Execution, error) { - if err := ctx.Err(); err != nil { - return game.Execution{}, err - } - if e.closed { - return game.Execution{}, fmt.Errorf("Docker test environment is closed") - } - fields := strings.Fields(line) - if len(fields) == 3 && fields[0] == "docker" && fields[1] == "ps" && fields[2] == "-a" { - return game.Execution{ - Output: "CONTAINER ID NAME STATUS\nlab-01 api Exited\nlab-02 metrics Up\n", - PracticedCommands: []string{"docker"}, - PipelineWidth: 1, - }, nil - } - if len(fields) == 3 && fields[0] == "docker" && fields[1] == "start" && fields[2] == "api" { - e.running["api"] = true - return game.Execution{Output: "api\n", PracticedCommands: []string{"docker"}, PipelineWidth: 1}, nil - } - return game.Execution{}, fmt.Errorf("unsupported Docker test command %q", line) -} - -func (e *cliDockerEnvironment) Observe(ctx context.Context, condition mission.Condition) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - switch condition.Type { - case "docker_container_running": - return e.running[condition.Container], nil - case "docker_container_count_equals": - if condition.Count == nil { - return false, fmt.Errorf("missing container count") - } - return e.containerCount == *condition.Count, nil - default: - return false, fmt.Errorf("unsupported Docker test observation %q", condition.Type) - } -} - -func (e *cliDockerEnvironment) Close() error { - e.closed = true - return nil -} - -type cliJanitorFactory struct { - cliDockerFactory - orphans int - checkErr error - removeErr error - checks int - removeCalls int -} - -func (f *cliJanitorFactory) OrphanedResources(context.Context) (int, error) { - f.checks++ - return f.orphans, f.checkErr -} - -func (f *cliJanitorFactory) RemoveOrphanedResources(context.Context) (int, error) { - f.removeCalls++ - if f.removeErr != nil { - return 1, f.removeErr - } - removed := f.orphans - f.orphans = 0 - return removed, nil -} - -func TestDoctorReportsAndCleansOrphanedDockerLabs(t *testing.T) { - run := func(t *testing.T, factory game.Factory, args ...string) (string, error) { - t.Helper() - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - out := &bytes.Buffer{} - app := New(Config{Out: out, ErrOut: &bytes.Buffer{}, Catalog: catalog, Store: store, Factory: factory}) - err = app.Run(append([]string{"doctor"}, args...)) - return out.String(), err - } - - t.Run("plain doctor is read-only", func(t *testing.T) { - factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "Docker is ready for this mission."}, orphans: 2} - output, err := run(t, factory) - if err != nil || !strings.Contains(output, "docker cleanup: 2 orphaned lab resources from exited OpsQuest processes; run 'opsquest doctor --cleanup'") { - t.Fatalf("doctor output = %s, error = %v", output, err) - } - if factory.checks != 1 || factory.removeCalls != 0 { - t.Fatalf("checks = %d, removals = %d", factory.checks, factory.removeCalls) - } - }) - - t.Run("clean engine", func(t *testing.T) { - factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}} - output, err := run(t, factory) - if err != nil || !strings.Contains(output, "docker cleanup: no orphaned lab resources") { - t.Fatalf("doctor output = %s, error = %v", output, err) - } - }) - - t.Run("check failure is a warning", func(t *testing.T) { - factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}, checkErr: fmt.Errorf("list failed")} - output, err := run(t, factory) - if err != nil || !strings.Contains(output, "could not check for orphaned lab resources · list failed") { - t.Fatalf("doctor output = %s, error = %v", output, err) - } - }) - - t.Run("unavailable engine skips the check", func(t *testing.T) { - factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{detail: "Docker labs unavailable"}, orphans: 2} - output, err := run(t, factory) - if err != nil || strings.Contains(output, "docker cleanup") || factory.checks != 0 { - t.Fatalf("doctor output = %s, error = %v, checks = %d", output, err, factory.checks) - } - }) - - t.Run("cleanup removes orphans", func(t *testing.T) { - factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}, orphans: 1} - output, err := run(t, factory, "--cleanup") - if err != nil || !strings.Contains(output, "docker cleanup: removed 1 orphaned lab resource\n") || factory.removeCalls != 1 { - t.Fatalf("doctor output = %s, error = %v, removals = %d", output, err, factory.removeCalls) - } - }) - - t.Run("cleanup failure fails the command", func(t *testing.T) { - factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}, orphans: 2, removeErr: fmt.Errorf("daemon went away")} - output, err := run(t, factory, "--cleanup") - if err == nil || !strings.Contains(err.Error(), "docker cleanup failed") || !strings.Contains(output, "removed 1, then failed · daemon went away") { - t.Fatalf("doctor output = %s, error = %v", output, err) - } - }) - - t.Run("cleanup failure before removal", func(t *testing.T) { - factory := &zeroRemovalJanitor{&cliJanitorFactory{cliDockerFactory: cliDockerFactory{detail: "unavailable"}, removeErr: fmt.Errorf("docker executable not found in PATH")}} - output, err := run(t, factory, "--cleanup") - if err == nil || !strings.Contains(output, "docker cleanup: failed · docker executable not found in PATH") || strings.Contains(output, "removed 0") { - t.Fatalf("doctor output = %s, error = %v", output, err) - } - }) - - t.Run("cleanup without Docker labs", func(t *testing.T) { - output, err := run(t, game.SandboxFactory{}, "--cleanup") - if err != nil || !strings.Contains(output, "nothing to clean; Docker labs are not configured") { - t.Fatalf("doctor output = %s, error = %v", output, err) - } - }) - - t.Run("rejects positional arguments", func(t *testing.T) { - if _, err := run(t, game.SandboxFactory{}, "cleanup"); err == nil { - t.Fatal("doctor accepted a positional argument") - } - }) -} - -// zeroRemovalJanitor fails before removing anything, as when the Docker CLI -// is absent. -type zeroRemovalJanitor struct { - *cliJanitorFactory -} - -func (f *zeroRemovalJanitor) RemoveOrphanedResources(context.Context) (int, error) { - f.removeCalls++ - return 0, f.removeErr -} diff --git a/internal/cli/docker_test.go b/internal/cli/docker_test.go new file mode 100644 index 0000000..b0e4109 --- /dev/null +++ b/internal/cli/docker_test.go @@ -0,0 +1,180 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func TestProfileDefersDockerReadinessToDoctor(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "", store) + factory := &cliDockerFactory{available: true, detail: "test Docker engine ready"} + app.factory = factory + + if err := app.Run([]string{"profile"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + if factory.availabilityChecks != 0 { + t.Fatalf("profile availability checks = %d, want 0", factory.availabilityChecks) + } + if !strings.Contains(out.String(), "readiness: run opsquest doctor") { + t.Fatalf("profile did not direct readiness checks to doctor:\n%s", out.String()) + } +} + +func TestListDockerTrackAndRejectsUnknownTrack(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "", store) + app.factory = &cliDockerFactory{available: true, detail: "test Docker engine ready"} + if err := app.Run([]string{"list", "--track", "docker", "--ids"}); err != nil { + t.Fatalf("list Docker track error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{ + "DOCKER LABS", + "Container Census", + "Shift Handoff", + "docker-container-census", + "WORLD 2/3 · Container Triage", + "Postmortem Triage", + "WORLD 3/3 · Network Plumbing", + "Segmentation", + "0/16 missions complete", + "Continue: opsquest play --track docker", + "Jump: opsquest play --track docker --world N", + "IDs: opsquest map --track docker --ids", + "Docker labs ready.", + } { + if !strings.Contains(out.String(), expected) { + t.Errorf("Docker list output missing %q:\n%s", expected, out.String()) + } + } + if strings.Contains(out.String(), "Where Am I?") { + t.Fatalf("Docker list leaked a Linux mission:\n%s", out.String()) + } + if got := app.factory.(*cliDockerFactory).availabilityChecks; got != 1 { + t.Fatalf("Docker list availability checks = %d, want 1", got) + } + + app, _, _ = testApp(t, "", store) + if err := app.Run([]string{"list", "--track", "mainframe"}); err == nil || !strings.Contains(err.Error(), "use linux, docker, or all") { + t.Fatalf("unknown track error = %v", err) + } +} + +func TestShowDockerMissionReportsReadinessAndToolHints(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "", store) + app.factory = &cliDockerFactory{available: true, detail: "test Docker engine ready"} + if err := app.Run([]string{"show", "20"}); err != nil { + t.Fatalf("show Docker mission error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{ + "MISSION 20: Container Census", + "Track: Docker", + "Outcome checks: 3", + "Hints: 0 used · 3 remaining · 3 total", + "Commands you may need to solve this level:", + " docker", + "Docker lab ready.", + } { + if !strings.Contains(out.String(), expected) { + t.Errorf("Docker show output missing %q:\n%s", expected, out.String()) + } + } +} + +func TestFreshDockerPlayDoesNotShowLinuxOnboarding(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "quit\n", store) + app.factory = &cliDockerFactory{available: true, detail: "test Docker engine ready"} + if err := app.Run([]string{"play", "--once", "20"}); err != nil { + t.Fatalf("Docker play error = %v; stderr = %s", err, errOut.String()) + } + if strings.Contains(out.String(), "WELCOME TO OPSQUEST") || strings.Contains(out.String(), "Start with the suggested command under the objective") { + t.Fatalf("Docker play received Linux-specific onboarding:\n%s", out.String()) + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if player.Onboarded { + t.Fatal("skipping Linux onboarding for Docker marked it as viewed") + } +} + +func TestUnavailableDockerMissionIsActionableAndDoesNotComplete(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, _ := testApp(t, "", store) + app.factory = &cliDockerFactory{ + available: false, + detail: "Docker labs unavailable: install Docker and start the daemon", + } + err := app.Run([]string{"play", "20"}) + if err == nil || !strings.Contains(err.Error(), "install Docker and start the daemon") { + t.Fatalf("unavailable Docker play error = %v", err) + } + player, loadErr := store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + if player.IsComplete("docker-container-census") || player.XP != 0 || len(player.Commands) != 0 { + t.Fatalf("unavailable Docker play changed profile = %#v", player) + } + if got := app.factory.(*cliDockerFactory).availabilityChecks; got != 0 { + t.Fatalf("Docker play availability checks = %d, want Create to be the only readiness path", got) + } +} + +func TestDockerMissionToolHintsAndOutcomeCompletionPersist(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + input := strings.Join([]string{ + "hint", + "hint", + "hint", + "docker ps -a", + "docker start api", + }, "\n") + "\n" + app, out, errOut := testApp(t, input, store) + factory := &cliDockerFactory{available: true, detail: "test Docker engine ready"} + app.factory = factory + if err := app.Run([]string{"play", "--once", "20"}); err != nil { + t.Fatalf("play Docker mission error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{ + "Hint 1/3 (-10 XP): Images are reusable templates", + "Hint 2/3 (-10 XP): Use docker ps -a", + "Hint 3/3 (-10 XP): Start the existing container with docker start api", + "Progress — 2/3 outcome checks satisfied", + "✓ Mission complete!", + "+20 XP", + "New command discovered: docker", + } { + if !strings.Contains(out.String(), expected) { + t.Errorf("Docker play output missing %q:\n%s", expected, out.String()) + } + } + if factory.created != 1 { + t.Fatalf("Docker environments created = %d, want 1", factory.created) + } + if factory.availabilityChecks != 0 { + t.Fatalf("Docker play availability checks = %d, want Create to be the only readiness path", factory.availabilityChecks) + } + + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + completion, complete := player.Completed["docker-container-census"] + if !complete || player.XP != 20 || completion.XP != 20 || completion.HintsUsed != 3 { + t.Fatalf("Docker completion profile = %#v", player) + } + if got := player.Commands["docker"]; got != 2 { + t.Fatalf("docker command mastery count = %d, want 2", got) + } + if player.MissionHints("docker-container-census") != 0 { + t.Fatal("completed Docker mission retained in-progress hints") + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go new file mode 100644 index 0000000..d86921c --- /dev/null +++ b/internal/cli/doctor.go @@ -0,0 +1,81 @@ +package cli + +import ( + "fmt" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func (a *App) runDoctor(args []string) error { + flags := a.newFlagSet("doctor", func() { fmt.Fprintln(a.errOut, "Usage: opsquest doctor [--cleanup]") }) + cleanup := flags.Bool("cleanup", false, "remove Docker lab containers left behind by exited OpsQuest processes") + if help, err := parseFlags(flags, args); help || err != nil { + return err + } + if flags.NArg() > 0 { + return fmt.Errorf("doctor does not accept positional arguments") + } + player, err := a.loadPlayer() + if err != nil { + return fmt.Errorf("profile check failed: %w", err) + } + fmt.Fprintln(a.out, a.style.Header("OpsQuest diagnostics")) + check := a.style.Success("✓") + fmt.Fprintf(a.out, " %s embedded catalog: %d missions (%d Linux, %d Docker)\n", check, len(a.catalog.All()), len(a.catalog.InTrack(mission.TrackLinux)), len(a.catalog.InTrack(mission.TrackDocker))) + fmt.Fprintf(a.out, " %s profile: version %d, %d completed missions\n", check, player.Version, len(player.Completed)) + fmt.Fprintf(a.out, " %s profile path: %s\n", check, a.store.Path()) + fmt.Fprintf(a.out, " %s Linux labs: in-memory; no host shell or filesystem access\n", check) + dockerReady := false + if item, found := a.catalog.FirstInTrack(mission.TrackDocker); found { + availability := game.EnvironmentAvailability(a.ctx, a.factory, item) + dockerReady = availability.Available + if availability.Available { + fmt.Fprintf(a.out, " %s docker labs: ready · %s\n", check, availability.Detail) + } else { + fmt.Fprintf(a.out, " %s docker labs: unavailable · %s\n", a.style.Warning("!"), availability.Detail) + } + } + return a.reportOrphanedLabs(*cleanup, dockerReady) +} + +// reportOrphanedLabs checks for, or with cleanup removes, lab resources left +// behind by OpsQuest processes that exited without closing their attempt. +// A plain doctor run stays read-only and only checks a reachable engine. +func (a *App) reportOrphanedLabs(cleanup, dockerReady bool) error { + janitor, supported := a.factory.(game.ResourceJanitor) + check := a.style.Success("✓") + warning := a.style.Warning("!") + if !supported { + if cleanup { + fmt.Fprintf(a.out, " %s docker cleanup: nothing to clean; Docker labs are not configured\n", check) + } + return nil + } + if cleanup { + removed, err := janitor.RemoveOrphanedResources(a.ctx) + if err != nil { + if removed > 0 { + fmt.Fprintf(a.out, " %s docker cleanup: removed %d, then failed · %v\n", warning, removed, err) + } else { + fmt.Fprintf(a.out, " %s docker cleanup: failed · %v\n", warning, err) + } + return fmt.Errorf("docker cleanup failed: %w", err) + } + fmt.Fprintf(a.out, " %s docker cleanup: removed %d orphaned lab %s\n", check, removed, plural(removed, "resource", "resources")) + return nil + } + if !dockerReady { + return nil + } + count, err := janitor.OrphanedResources(a.ctx) + switch { + case err != nil: + fmt.Fprintf(a.out, " %s docker cleanup: could not check for orphaned lab resources · %v\n", warning, err) + case count > 0: + fmt.Fprintf(a.out, " %s docker cleanup: %d orphaned lab %s from exited OpsQuest processes; run 'opsquest doctor --cleanup'\n", warning, count, plural(count, "resource", "resources")) + default: + fmt.Fprintf(a.out, " %s docker cleanup: no orphaned lab resources\n", check) + } + return nil +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go new file mode 100644 index 0000000..af0e51f --- /dev/null +++ b/internal/cli/doctor_test.go @@ -0,0 +1,100 @@ +package cli + +import ( + "bytes" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func TestDoctorReportsAndCleansOrphanedDockerLabs(t *testing.T) { + run := func(t *testing.T, factory game.Factory, args ...string) (string, error) { + t.Helper() + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + out := &bytes.Buffer{} + app := New(Config{Out: out, ErrOut: &bytes.Buffer{}, Catalog: catalog, Store: store, Factory: factory}) + err = app.Run(append([]string{"doctor"}, args...)) + return out.String(), err + } + + t.Run("plain doctor is read-only", func(t *testing.T) { + factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "Docker is ready for this mission."}, orphans: 2} + output, err := run(t, factory) + if err != nil || !strings.Contains(output, "docker cleanup: 2 orphaned lab resources from exited OpsQuest processes; run 'opsquest doctor --cleanup'") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + if factory.checks != 1 || factory.removeCalls != 0 { + t.Fatalf("checks = %d, removals = %d", factory.checks, factory.removeCalls) + } + }) + + t.Run("clean engine", func(t *testing.T) { + factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}} + output, err := run(t, factory) + if err != nil || !strings.Contains(output, "docker cleanup: no orphaned lab resources") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + t.Run("check failure is a warning", func(t *testing.T) { + factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}, checkErr: fmt.Errorf("list failed")} + output, err := run(t, factory) + if err != nil || !strings.Contains(output, "could not check for orphaned lab resources · list failed") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + t.Run("unavailable engine skips the check", func(t *testing.T) { + factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{detail: "Docker labs unavailable"}, orphans: 2} + output, err := run(t, factory) + if err != nil || strings.Contains(output, "docker cleanup") || factory.checks != 0 { + t.Fatalf("doctor output = %s, error = %v, checks = %d", output, err, factory.checks) + } + }) + + t.Run("cleanup removes orphans", func(t *testing.T) { + factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}, orphans: 1} + output, err := run(t, factory, "--cleanup") + if err != nil || !strings.Contains(output, "docker cleanup: removed 1 orphaned lab resource\n") || factory.removeCalls != 1 { + t.Fatalf("doctor output = %s, error = %v, removals = %d", output, err, factory.removeCalls) + } + }) + + t.Run("cleanup failure fails the command", func(t *testing.T) { + factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}, orphans: 2, removeErr: fmt.Errorf("daemon went away")} + output, err := run(t, factory, "--cleanup") + if err == nil || !strings.Contains(err.Error(), "docker cleanup failed") || !strings.Contains(output, "removed 1, then failed · daemon went away") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + t.Run("cleanup failure before removal", func(t *testing.T) { + factory := &zeroRemovalJanitor{&cliJanitorFactory{cliDockerFactory: cliDockerFactory{detail: "unavailable"}, removeErr: fmt.Errorf("docker executable not found in PATH")}} + output, err := run(t, factory, "--cleanup") + if err == nil || !strings.Contains(output, "docker cleanup: failed · docker executable not found in PATH") || strings.Contains(output, "removed 0") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + t.Run("cleanup without Docker labs", func(t *testing.T) { + output, err := run(t, game.SandboxFactory{}, "--cleanup") + if err != nil || !strings.Contains(output, "nothing to clean; Docker labs are not configured") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + t.Run("rejects positional arguments", func(t *testing.T) { + if _, err := run(t, game.SandboxFactory{}, "cleanup"); err == nil { + t.Fatal("doctor accepted a positional argument") + } + }) +} diff --git a/internal/cli/fake_docker_test.go b/internal/cli/fake_docker_test.go new file mode 100644 index 0000000..4e7d90d --- /dev/null +++ b/internal/cli/fake_docker_test.go @@ -0,0 +1,131 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +type cliDockerFactory struct { + available bool + detail string + created int + availabilityChecks int +} + +func (f *cliDockerFactory) Availability(_ context.Context, item mission.Mission) game.Availability { + f.availabilityChecks++ + if item.EffectiveEnvironment() != mission.EnvironmentDocker { + return game.Availability{Available: true, Detail: "in-memory sandbox ready"} + } + return game.Availability{Available: f.available, Detail: f.detail} +} + +func (f *cliDockerFactory) Create(ctx context.Context, item mission.Mission) (game.Environment, error) { + if item.EffectiveEnvironment() != mission.EnvironmentDocker { + return (game.SandboxFactory{}).Create(ctx, item) + } + if !f.available { + return nil, fmt.Errorf("%s", f.detail) + } + f.created++ + return &cliDockerEnvironment{ + running: map[string]bool{ + "api": false, + "metrics": true, + }, + containerCount: 2, + }, nil +} + +type cliDockerEnvironment struct { + running map[string]bool + containerCount int + closed bool +} + +func (e *cliDockerEnvironment) PromptLabel() string { return "docker" } + +func (e *cliDockerEnvironment) CompletionSource() game.CompletionSource { return nil } + +func (e *cliDockerEnvironment) Execute(ctx context.Context, line string) (game.Execution, error) { + if err := ctx.Err(); err != nil { + return game.Execution{}, err + } + if e.closed { + return game.Execution{}, fmt.Errorf("Docker test environment is closed") + } + fields := strings.Fields(line) + if len(fields) == 3 && fields[0] == "docker" && fields[1] == "ps" && fields[2] == "-a" { + return game.Execution{ + Output: "CONTAINER ID NAME STATUS\nlab-01 api Exited\nlab-02 metrics Up\n", + PracticedCommands: []string{"docker"}, + PipelineWidth: 1, + }, nil + } + if len(fields) == 3 && fields[0] == "docker" && fields[1] == "start" && fields[2] == "api" { + e.running["api"] = true + return game.Execution{Output: "api\n", PracticedCommands: []string{"docker"}, PipelineWidth: 1}, nil + } + return game.Execution{}, fmt.Errorf("unsupported Docker test command %q", line) +} + +func (e *cliDockerEnvironment) Observe(ctx context.Context, condition mission.Condition) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + switch condition.Type { + case "docker_container_running": + return e.running[condition.Container], nil + case "docker_container_count_equals": + if condition.Count == nil { + return false, fmt.Errorf("missing container count") + } + return e.containerCount == *condition.Count, nil + default: + return false, fmt.Errorf("unsupported Docker test observation %q", condition.Type) + } +} + +func (e *cliDockerEnvironment) Close() error { + e.closed = true + return nil +} + +type cliJanitorFactory struct { + cliDockerFactory + orphans int + checkErr error + removeErr error + checks int + removeCalls int +} + +func (f *cliJanitorFactory) OrphanedResources(context.Context) (int, error) { + f.checks++ + return f.orphans, f.checkErr +} + +func (f *cliJanitorFactory) RemoveOrphanedResources(context.Context) (int, error) { + f.removeCalls++ + if f.removeErr != nil { + return 1, f.removeErr + } + removed := f.orphans + f.orphans = 0 + return removed, nil +} + +// zeroRemovalJanitor fails before removing anything, as when the Docker CLI +// is absent. +type zeroRemovalJanitor struct { + *cliJanitorFactory +} + +func (f *zeroRemovalJanitor) RemoveOrphanedResources(context.Context) (int, error) { + f.removeCalls++ + return 0, f.removeErr +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go new file mode 100644 index 0000000..3c4b733 --- /dev/null +++ b/internal/cli/flags.go @@ -0,0 +1,37 @@ +package cli + +import ( + "errors" + "flag" + "strings" +) + +func (a *App) newFlagSet(name string, usage func()) *flag.FlagSet { + flags := flag.NewFlagSet(name, flag.ContinueOnError) + flags.SetOutput(a.errOut) + flags.Usage = usage + return flags +} + +func parseFlags(flags *flag.FlagSet, args []string) (bool, error) { + err := flags.Parse(args) + if errors.Is(err, flag.ErrHelp) { + return true, nil + } + return false, err +} + +func flagProvided(flags *flag.FlagSet, name string) bool { + provided := false + flags.Visit(func(item *flag.Flag) { provided = provided || item.Name == name }) + return provided +} + +func hasFlag(args []string, name string) bool { + for _, arg := range args { + if arg == name || strings.HasPrefix(arg, name+"=") { + return true + } + } + return false +} diff --git a/internal/cli/format.go b/internal/cli/format.go new file mode 100644 index 0000000..a786a83 --- /dev/null +++ b/internal/cli/format.go @@ -0,0 +1,73 @@ +package cli + +import ( + "strings" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" + "github.com/aleksandergregersen/opsquest/internal/ui" +) + +func progressBar(value, total, width int) string { + filled := progressFilled(value, total, width) + return strings.Repeat("█", filled) + strings.Repeat("░", width-filled) +} + +func styledProgressBar(style ui.Style, value, total, width int) string { + filled := progressFilled(value, total, width) + return style.Progress(strings.Repeat("█", filled), strings.Repeat("░", width-filled)) +} + +func progressFilled(value, total, width int) int { + filled := 0 + if total > 0 { + filled = value * width / total + } + return min(max(filled, 0), width) +} + +func percentage(value, total int) int { + if total == 0 { + return 0 + } + return value * 100 / total +} + +func plural(value int, singular, plural string) string { + if value == 1 { + return singular + } + return plural +} + +func completedMissions(items []mission.Mission, player profile.Profile) int { + completed := 0 + for _, item := range items { + if player.IsComplete(item.ID) { + completed++ + } + } + return completed +} + +func trackDisplayName(track string) string { + switch track { + case mission.TrackDocker: + return "Docker" + case mission.TrackLinux: + return "Linux" + default: + return track + } +} + +func trackHeading(track string) string { + switch track { + case mission.TrackDocker: + return "DOCKER LABS" + case "all": + return "ALL MISSIONS" + default: + return "LINUX CAMPAIGN" + } +} diff --git a/internal/cli/guide_test.go b/internal/cli/guide_test.go new file mode 100644 index 0000000..3621d25 --- /dev/null +++ b/internal/cli/guide_test.go @@ -0,0 +1,70 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func TestFreshPlayExplainsTheGameAndGuideIsReplayable(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "pwd\n", store) + if err := app.Run([]string{"play", "1"}); err != nil { + t.Fatalf("fresh play error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{ + "WELCOME TO OPSQUEST", + "final outcome is what counts", + "Hints are always available", + "opsquest map", + "never reach your host shell or files", + } { + if !strings.Contains(out.String(), expected) { + t.Errorf("fresh guide missing %q:\n%s", expected, out.String()) + } + } + + app, out, errOut = testApp(t, "quit\n", store) + if err := app.Run([]string{"play", "2"}); err != nil { + t.Fatalf("returning play error = %v; stderr = %s", err, errOut.String()) + } + if strings.Contains(out.String(), "WELCOME TO OPSQUEST") { + t.Fatalf("first-activity guide repeated after progress:\n%s", out.String()) + } + + app, out, errOut = testApp(t, "", store) + if err := app.Run([]string{"guide"}); err != nil { + t.Fatalf("guide command error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "WELCOME TO OPSQUEST") { + t.Fatalf("guide command did not replay onboarding:\n%s", out.String()) + } +} + +func TestFirstRunGuideIsRememberedAfterImmediateQuit(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "quit\n", store) + if err := app.Run([]string{"play", "1"}); err != nil { + t.Fatalf("first play error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "WELCOME TO OPSQUEST") { + t.Fatalf("first play omitted quick start:\n%s", out.String()) + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if !player.Onboarded { + t.Fatal("first-play onboarding marker was not persisted") + } + + app, out, errOut = testApp(t, "quit\n", store) + if err := app.Run([]string{"play", "1"}); err != nil { + t.Fatalf("second play error = %v; stderr = %s", err, errOut.String()) + } + if strings.Contains(out.String(), "WELCOME TO OPSQUEST") { + t.Fatalf("quick start repeated after an immediate quit:\n%s", out.String()) + } +} diff --git a/internal/cli/help.go b/internal/cli/help.go new file mode 100644 index 0000000..f119271 --- /dev/null +++ b/internal/cli/help.go @@ -0,0 +1,77 @@ +package cli + +import ( + "fmt" + "io" +) + +func (a *App) runHelp(args []string) error { + if len(args) == 0 || len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { + a.printUsage() + return nil + } + if len(args) > 1 { + return fmt.Errorf("usage: opsquest help [COMMAND]") + } + switch args[0] { + case "play": + a.printPlayUsage(a.out) + case "guide", "tutorial": + fmt.Fprintln(a.out, "Usage: opsquest guide") + case "list", "campaign", "map", "worlds": + a.printListUsage(a.out) + case "profile": + a.printProfileUsage(a.out) + case "commands": + fmt.Fprintln(a.out, "Usage: opsquest commands") + case "achievements": + fmt.Fprintln(a.out, "Usage: opsquest achievements") + case "show", "mission": + fmt.Fprintln(a.out, "Usage: opsquest show [MISSION]") + case "doctor": + fmt.Fprintln(a.out, "Usage: opsquest doctor [--cleanup]") + case "reset": + fmt.Fprintln(a.out, "Usage: opsquest reset [--yes]") + case "version": + fmt.Fprintln(a.out, "Usage: opsquest version") + default: + return fmt.Errorf("unknown command %q", args[0]) + } + return nil +} + +func (a *App) printPlayUsage(out io.Writer) { + fmt.Fprintln(out, "Usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [--web] [MISSION]") + fmt.Fprintln(out, "Without a selector, resume the first incomplete mission and continue the recommended path.") + fmt.Fprintln(out, "A mission number or ID follows catalog order from that exact stage; --world stays inside one world.") + fmt.Fprintln(out, "Use --once to return after one completed mission.") + fmt.Fprintln(out, "Use --web to show guidance and live objective progress in a local browser companion.") +} + +func (a *App) printListUsage(out io.Writer) { + fmt.Fprintln(out, "Usage: opsquest map|list [--completed|--remaining] [--ids] [--campaign NAME] [--track linux|docker|all]") +} + +func (a *App) printProfileUsage(out io.Writer) { + fmt.Fprintln(out, "Usage: opsquest profile [--name NAME]") +} + +func (a *App) printUsage() { + fmt.Fprintln(a.out, a.style.Header("OpsQuest")+" — learn operations by fixing fictional production") + fmt.Fprintln(a.out) + fmt.Fprintln(a.out, `Usage: + opsquest play [OPTIONS] Follow the recommended path, one world, or one mission + opsquest guide Replay the getting-started guide + opsquest map View worlds, stages, and completion progress + opsquest list Alias for map; accepts progress filters + opsquest profile Show rank, XP, and world progress + opsquest commands Show commands practiced successfully + opsquest achievements Show learning achievements + opsquest show [MISSION] Preview a mission without starting it + opsquest doctor Check the catalog, profile, safety mode, and lab cleanup + opsquest reset [--yes] Reset local progress + opsquest version Print the version + +New here: opsquest guide +Start playing: opsquest play`) +} diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go new file mode 100644 index 0000000..8129cdf --- /dev/null +++ b/internal/cli/helpers_test.go @@ -0,0 +1,82 @@ +package cli + +import ( + "bytes" + "context" + "regexp" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +var sgrPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +var linuxMissionIDsThroughArchive = []string{ + "linux-orientation", + "linux-config-crawl", + "linux-read-handoff", + "linux-workspace", + "linux-find-logs", + "linux-release-shuffle", + "linux-permissions", + "linux-environment", + "linux-log-preview", + "linux-runaway", + "linux-archive-rescue", +} + +func testApp(t *testing.T, input string, store profile.Store) (*App, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + return New(Config{ + In: strings.NewReader(input), + Out: out, + ErrOut: errOut, + Catalog: catalog, + Store: store, + }), out, errOut +} + +type testWebCompanion struct { + events []game.AttemptEvent + closeCount int + closeErr error +} + +func (c *testWebCompanion) ReportAttempt(event game.AttemptEvent) { + c.events = append(c.events, game.CloneAttemptEvent(event)) +} + +func (c *testWebCompanion) URL() string { return "http://127.0.0.1:43210/pair?token=test" } + +func (c *testWebCompanion) Close(context.Context) error { + c.closeCount++ + return c.closeErr +} + +func seedCompletedMissions(t *testing.T, store profile.Store, missionIDs ...string) { + t.Helper() + player := profile.New("alex") + for _, id := range missionIDs { + player.Complete(id, 0, 0, time.Unix(1, 0)) + } + if err := store.Save(player); err != nil { + t.Fatal(err) + } +} + +func eventTypesForCLI(events []game.AttemptEvent) []game.AttemptEventType { + types := make([]game.AttemptEventType, len(events)) + for index, event := range events { + types[index] = event.Type + } + return types +} diff --git a/internal/cli/map.go b/internal/cli/map.go new file mode 100644 index 0000000..d57ca79 --- /dev/null +++ b/internal/cli/map.go @@ -0,0 +1,177 @@ +package cli + +import ( + "fmt" + "slices" + "strings" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func (a *App) runList(args []string) error { + player, err := a.loadPlayer() + if err != nil { + return err + } + return a.listMissions(args, player, false) +} + +// mapFilter is the validated scope and display options of one map listing. +type mapFilter struct { + track string + campaign string + completedOnly bool + remainingOnly bool + showIDs bool +} + +// inScope reports whether item belongs to the selected track and campaign, +// regardless of completion. +func (f mapFilter) inScope(item mission.Mission) bool { + if f.track != "all" && item.EffectiveTrack() != f.track { + return false + } + return f.campaign == "" || strings.EqualFold(item.Campaign, f.campaign) +} + +// shows reports whether item is listed once completion filters apply. +func (f mapFilter) shows(item mission.Mission, player profile.Profile) bool { + if !f.inScope(item) { + return false + } + complete := player.IsComplete(item.ID) + return !(f.completedOnly && !complete || f.remainingOnly && complete) +} + +func (a *App) listMissions(args []string, player profile.Profile, inMission bool) error { + filter, help, err := a.parseMapFilter(args) + if help || err != nil { + return err + } + heading := trackHeading(filter.track) + if strings.TrimSpace(filter.campaign) != "" { + heading = "WORLD · " + filter.campaign + } + fmt.Fprintln(a.out, a.style.Header(heading)) + shown := 0 + displayedTracks := make(map[string]bool) + tracks := []string{filter.track} + if filter.track == "all" { + tracks = []string{mission.TrackLinux, mission.TrackDocker} + } + for _, currentTrack := range tracks { + worlds := a.catalog.Worlds(currentTrack) + for _, world := range worlds { + visible := slices.DeleteFunc(slices.Clone(world.Missions), func(item mission.Mission) bool { return !filter.shows(item, player) }) + if len(visible) == 0 { + continue + } + displayedTracks[currentTrack] = true + if shown > 0 { + fmt.Fprintln(a.out) + } + a.printMapWorld(world, len(worlds), visible, filter, player) + shown += len(visible) + } + } + if shown == 0 { + fmt.Fprintln(a.out, " No missions match these filters.") + } + scoped := slices.DeleteFunc(a.catalog.All(), func(item mission.Mission) bool { return !filter.inScope(item) }) + fmt.Fprintf(a.out, "\n%s\n", a.style.Accent(fmt.Sprintf("%d/%d missions complete · Player total: %d XP", completedMissions(scoped, player), len(scoped), player.XP))) + if shown > 0 { + a.printMapNavigation(filter.track, displayedTracks, inMission) + } + if filter.track == mission.TrackDocker { + a.printDockerReadiness() + } + return nil +} + +func (a *App) parseMapFilter(args []string) (mapFilter, bool, error) { + flags := a.newFlagSet("list", func() { a.printListUsage(a.errOut) }) + completedOnly := flags.Bool("completed", false, "show completed missions only") + remainingOnly := flags.Bool("remaining", false, "show incomplete missions only") + showIDs := flags.Bool("ids", false, "show stable mission IDs") + campaign := flags.String("campaign", "", "filter by campaign name") + track := flags.String("track", mission.TrackLinux, "filter by track: linux, docker, or all") + if help, err := parseFlags(flags, args); help || err != nil { + return mapFilter{}, help, err + } + if flags.NArg() > 0 { + return mapFilter{}, false, fmt.Errorf("list does not accept positional arguments") + } + if *completedOnly && *remainingOnly { + return mapFilter{}, false, fmt.Errorf("--completed and --remaining cannot be combined") + } + selectedTrack := strings.ToLower(strings.TrimSpace(*track)) + if *campaign != "" && !flagProvided(flags, "track") { + selectedTrack = "all" + } + if selectedTrack != mission.TrackLinux && selectedTrack != mission.TrackDocker && selectedTrack != "all" { + return mapFilter{}, false, fmt.Errorf("unknown track %q; use linux, docker, or all", *track) + } + return mapFilter{track: selectedTrack, campaign: *campaign, completedOnly: *completedOnly, remainingOnly: *remainingOnly, showIDs: *showIDs}, false, nil +} + +// printMapWorld prints one world heading and its visible stages. +func (a *App) printMapWorld(world mission.World, worldTotal int, visible []mission.Mission, filter mapFilter, player profile.Profile) { + prefix := fmt.Sprintf("WORLD %d/%d", world.Number, worldTotal) + if filter.track == "all" { + prefix = strings.ToUpper(trackDisplayName(world.Track)) + " · " + prefix + } + fmt.Fprintf(a.out, "%s %s\n", a.style.World(prefix+" · "+world.Name), a.style.Muted(fmt.Sprintf("%d/%d complete", completedMissions(world.Missions, player), len(world.Missions)))) + for _, item := range visible { + status := a.style.Muted("○") + if player.IsComplete(item.ID) { + status = a.style.Success("✓") + } + placement, _ := a.catalog.Placement(item.ID) + difficulty := a.style.Difficulty(item.Difficulty) + reward := a.style.Reward(fmt.Sprintf("%d XP", item.Rewards.XP)) + fmt.Fprintf(a.out, " %s Stage %d/%d · #%02d · %s · %s · %s", status, placement.StageNumber, placement.StageTotal, item.Number, item.Title, difficulty, reward) + if filter.showIDs { + fmt.Fprintf(a.out, " · %s", a.style.Muted(item.ID)) + } + fmt.Fprintln(a.out) + } +} + +// printMapNavigation prints the commands that continue from this listing: +// in-lab navigation inside a mission, or the matching play commands outside. +func (a *App) printMapNavigation(selectedTrack string, displayedTracks map[string]bool, inMission bool) { + if inMission { + fmt.Fprintf(a.out, "Navigate here: %s · %s · reveal IDs with %s\n", + a.style.Accent("world N"), a.style.Accent("play STAGE/ID"), a.style.Accent("list --ids")) + return + } + commandTrack := selectedTrack + if commandTrack == "all" && len(displayedTracks) == 1 { + for track := range displayedTracks { + commandTrack = track + } + } + follow, jump, ids := "opsquest play", "opsquest play --world N", "opsquest map --ids" + switch commandTrack { + case mission.TrackDocker: + follow, jump, ids = "opsquest play --track docker", "opsquest play --track docker --world N", "opsquest map --track docker --ids" + case "all": + follow, jump, ids = "opsquest play --track TRACK", "opsquest play --track TRACK --world N", "opsquest map --track all --ids" + } + fmt.Fprintf(a.out, "Continue: %s · Jump: %s · IDs: %s\n", + a.style.Accent(follow), a.style.Accent(jump), a.style.Accent(ids)) +} + +func (a *App) printDockerReadiness() { + item, found := a.catalog.FirstInTrack(mission.TrackDocker) + if !found { + return + } + if availability := game.EnvironmentAvailability(a.ctx, a.factory, item); availability.Available { + fmt.Fprintln(a.out, a.style.Success("Docker labs ready.")) + } else { + fmt.Fprintln(a.out, a.style.Warning(availability.Detail)) + } +} diff --git a/internal/cli/map_test.go b/internal/cli/map_test.go new file mode 100644 index 0000000..6e0b01f --- /dev/null +++ b/internal/cli/map_test.go @@ -0,0 +1,79 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func TestMapShowsTrackLocalWorldsAndStages(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "", store) + if err := app.Run([]string{"map"}); err != nil { + t.Fatalf("map error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{ + "WORLD 1/4 · First Day", + "Stage 6/6 · #06", + "WORLD 2/4 · The Logpocalypse", + "WORLD 4/4 · The Automation Shift", + "Jump: opsquest play --world N", + } { + if !strings.Contains(out.String(), expected) { + t.Errorf("map output missing %q:\n%s", expected, out.String()) + } + } + if strings.Contains(out.String(), "Container Census") { + t.Fatalf("default Linux map included the optional Docker track:\n%s", out.String()) + } +} + +func TestListAndProfileWorkWithoutExistingSave(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "newbie") + app, out, _ := testApp(t, "", store) + if err := app.Run([]string{"list"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "0/23 missions complete") { + t.Fatalf("list output = %s", out.String()) + } + + app, out, _ = testApp(t, "", store) + if err := app.Run([]string{"profile"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Operator: newbie") || !strings.Contains(out.String(), "Rank: Intern") { + t.Fatalf("profile output = %s", out.String()) + } +} + +func TestListFiltersMissions(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + play, _, _ := testApp(t, "pwd\n", store) + if err := play.Run([]string{"play", "1"}); err != nil { + t.Fatal(err) + } + app, out, _ := testApp(t, "", store) + if err := app.Run([]string{"list", "--completed"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Where Am I?") || strings.Contains(out.String(), "Configuration Crawl") { + t.Fatalf("filtered list output = %s", out.String()) + } +} + +func TestListCampaignFilterUsesCampaignTotal(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "", store) + if err := app.Run([]string{"list", "--campaign", "First Day"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "0/6 missions complete") { + t.Fatalf("campaign-filtered total missing:\n%s", out.String()) + } + if strings.Contains(out.String(), "0/39 missions complete") || strings.Contains(out.String(), "Production Friday") { + t.Fatalf("campaign filter used catalog-wide scope:\n%s", out.String()) + } +} diff --git a/internal/cli/navigation_test.go b/internal/cli/navigation_test.go new file mode 100644 index 0000000..7e5f603 --- /dev/null +++ b/internal/cli/navigation_test.go @@ -0,0 +1,233 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func TestMissionPromptCanShowMapAndJumpWorlds(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "map\nworld 4\nquit\n", store) + if err := app.Run([]string{"play", "1"}); err != nil { + t.Fatalf("in-mission world navigation error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{ + "WORLD 4/4 · The Automation Shift", + "Switching to Mission 26: Run the Runbook", + "MISSION 26: Run the Runbook", + "Navigate here: world N", + } { + if !strings.Contains(out.String(), expected) { + t.Errorf("in-mission world navigation missing %q:\n%s", expected, out.String()) + } + } + if strings.Contains(errOut.String(), "command not available") { + t.Fatalf("world navigation reached the sandbox: %s", errOut.String()) + } +} + +func TestMissionPromptCanScopeTheCurrentStageToItsWorld(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + seedCompletedMissions(t, store, linuxMissionIDsThroughArchive[:5]...) + + app, out, errOut := testApp(t, "world 1\nmv incident-104.txt /archive/2026/incident-104.txt\n", store) + if err := app.Run([]string{"play"}); err != nil { + t.Fatalf("same-stage world selection error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{"World 1 route selected", "World 1 complete: First Day!"} { + if !strings.Contains(out.String(), expected) { + t.Fatalf("same-stage world selection missing %q:\n%s", expected, out.String()) + } + } + if strings.Contains(out.String(), "MISSION 07") { + t.Fatalf("same-stage world route crossed its boundary:\n%s", out.String()) + } +} + +func TestMissionPromptReportsInvalidWorldWithoutDispatchingIt(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, errOut := testApp(t, "world 0\nworld 99\nquit\n", store) + if err := app.Run([]string{"play", "1"}); err != nil { + t.Fatalf("play error = %v", err) + } + for _, expected := range []string{"world number must be a positive integer", "world 99 does not exist"} { + if !strings.Contains(errOut.String(), expected) { + t.Errorf("world navigation stderr missing %q: %s", expected, errOut.String()) + } + } + if strings.Contains(errOut.String(), "command not available") { + t.Fatalf("invalid world navigation reached the sandbox: %s", errOut.String()) + } +} + +func TestMissionPromptExplainsCompletedWorldReplay(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + seedCompletedMissions(t, store, linuxMissionIDsThroughArchive[:6]...) + app, out, errOut := testApp(t, "world 1\npwd\nquit\n", store) + if err := app.Run([]string{"play", "6"}); err != nil { + t.Fatalf("play error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "World 1 is complete; replaying Stage 1") || !strings.Contains(out.String(), "MISSION 01: Where Am I?") { + t.Fatalf("completed-world replay was not explained:\n%s", out.String()) + } + if !strings.Contains(out.String(), "Continuing to Mission 02: Configuration Crawl") || !strings.Contains(out.String(), "MISSION 02") { + t.Fatalf("completed-world replay did not retain its world route:\n%s", out.String()) + } +} + +func TestMissionPromptCanListAndSwitchMissions(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + seed, _, _ := testApp(t, "pwd\n", store) + if err := seed.Run([]string{"play", "1"}); err != nil { + t.Fatal(err) + } + + input := strings.Join([]string{ + "opsquest list --completed", + "opsquest play linux-workspace", + "mkdir -p reports/daily", + "list --completed", + "status", + "quit", + }, "\n") + "\n" + app, out, errOut := testApp(t, input, store) + if err := app.Run([]string{"play", "9"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + + output := out.String() + for _, expected := range []string{ + "LINUX CAMPAIGN", + "1/23 missions complete", + "Switching to Mission 04: A Place for Everything", + "MISSION 04: A Place for Everything", + "✓ Directory exists: /workspace/reports/daily", + "○ File exists: /workspace/reports/daily/summary.txt", + } { + if !strings.Contains(output, expected) { + t.Errorf("in-mission navigation output missing %q:\n%s", expected, output) + } + } + if strings.Contains(errOut.String(), "command not available") { + t.Fatalf("navigation reached the sandbox dispatcher: %s", errOut.String()) + } +} + +func TestMissionPromptUsesWorldLocalStageNumbers(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "play 4\nquit\n", store) + if err := app.Run([]string{"play", "7"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + + output := out.String() + for _, expected := range []string{ + "Navigate: map · world N · play STAGE/ID", + "Switching to Mission 10: The Runaway Worker", + "MISSION 10: The Runaway Worker", + "World 2/4: The Logpocalypse · Stage 4/6", + } { + if !strings.Contains(output, expected) { + t.Errorf("world-local stage navigation missing %q:\n%s", expected, output) + } + } + if strings.Contains(output, "MISSION 04: A Place for Everything") { + t.Fatalf("World 2 stage navigation fell back to the global Mission 04:\n%s", output) + } + if errOut.Len() != 0 { + t.Fatalf("world-local stage navigation stderr = %s", errOut.String()) + } +} + +func TestMissionPromptStageJumpContinuesIntoNextWorld(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + seedCompletedMissions(t, store, linuxMissionIDsThroughArchive[:6]...) + input := "play 6\nmv /incoming/incident-104.txt /archive/2026/incident-104.txt\nquit\n" + app, out, errOut := testApp(t, input, store) + if err := app.Run([]string{"play", "3"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + + output := out.String() + for _, expected := range []string{ + "Switching to Mission 06: The Release Shuffle", + "Replay complete", + "Continuing to Mission 07: Permission to Deploy", + "World 2/4: The Logpocalypse", + } { + if !strings.Contains(output, expected) { + t.Errorf("stage jump did not continue with %q:\n%s", expected, output) + } + } +} + +func TestMissionPromptHandlesCurrentAndOutOfRangeWorldStages(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "play 1\nplay 7\nquit\n", store) + if err := app.Run([]string{"play", "7"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + + if !strings.Contains(out.String(), "Already playing Mission 07: Permission to Deploy.") { + t.Fatalf("same-stage navigation was not explained:\n%s", out.String()) + } + if strings.Contains(out.String(), "Switching to Mission") { + t.Fatalf("same or invalid stage unexpectedly switched missions:\n%s", out.String()) + } + wantError := `stage "7" does not exist in World 2; choose a stage from 1 to 6 or use map` + if !strings.Contains(errOut.String(), wantError) { + t.Fatalf("out-of-range stage error missing %q: %s", wantError, errOut.String()) + } +} + +func TestMissionPromptParsesQuotedNavigationArguments(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + player := profile.New("alex") + player.RecordCommands([]string{"pwd"}) + if err := store.Save(player); err != nil { + t.Fatal(err) + } + app, out, errOut := testApp(t, "list --campaign \"First Day\"\nquit\n", store) + if err := app.Run([]string{"play", "3"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "Where Am I?") || !strings.Contains(out.String(), "0/6 missions complete") { + t.Fatalf("quoted campaign list output:\n%s", out.String()) + } + if strings.Contains(out.String(), "Production Friday") || errOut.Len() != 0 { + t.Fatalf("quoted campaign navigation leaked missions or failed; stdout:\n%s\nstderr:\n%s", out.String(), errOut.String()) + } +} + +func TestMissionPromptReportsMalformedNavigation(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "list --campaign \"First Day\nquit\n", store) + if err := app.Run([]string{"play", "3"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(errOut.String(), "invalid mission navigation: unterminated double quote") { + t.Fatalf("malformed navigation stderr = %q", errOut.String()) + } + if !strings.Contains(out.String(), "Mission paused") { + t.Fatalf("session did not continue after malformed navigation:\n%s", out.String()) + } +} + +func TestMissionPromptSupportsPreviousAndNext(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "previous\nnext\nquit\n", store) + if err := app.Run([]string{"play", "9"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{ + "Switching to Mission 08: Wrong Planet", + "Switching to Mission 09: Opening Lines", + } { + if !strings.Contains(out.String(), expected) { + t.Errorf("arrow-style navigation output missing %q:\n%s", expected, out.String()) + } + } +} diff --git a/internal/cli/play.go b/internal/cli/play.go new file mode 100644 index 0000000..ae08d61 --- /dev/null +++ b/internal/cli/play.go @@ -0,0 +1,297 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +type playRouteKind uint8 + +const ( + playRouteRecommended playRouteKind = iota + playRouteSequential + playRouteWorld +) + +type playRoute struct { + kind playRouteKind + worldNumber int +} + +// playOptions holds validated `opsquest play` flags and the optional +// positional mission reference. +type playOptions struct { + track string + worldNumber int + once bool + web bool + mission string +} + +func (a *App) runPlay(args []string) (returnErr error) { + options, help, err := a.parsePlayOptions(args) + if help || err != nil { + return err + } + player, err := a.loadPlayer() + if err != nil { + return err + } + item, route, found, err := a.startingMission(options, player) + if err != nil || !found { + return err + } + var activeCompanion Companion + if options.web { + activeCompanion, err = a.startCompanion(a.ctx) + if err != nil { + return fmt.Errorf("start web companion: %w", err) + } + defer func() { + if err := closeCompanion(activeCompanion); err != nil { + returnErr = errors.Join(returnErr, err) + } + }() + a.announceCompanion(activeCompanion) + } + if err := a.onboard(item, &player, options.web); err != nil { + return err + } + reader := game.NewCommandLineReader(a.in, a.out) + for { + wasComplete := player.IsComplete(item.ID) + result, err := a.newSession(item, &player, reader, activeCompanion).Run() + if err != nil { + return err + } + if result.SwitchMission != "" { + item, _ = a.catalog.Find(result.SwitchMission) + if result.WorldRoute > 0 { + route = playRoute{kind: playRouteWorld, worldNumber: result.WorldRoute} + } else { + route = playRoute{kind: playRouteSequential} + } + continue + } + if result.WorldRoute > 0 { + route = playRoute{kind: playRouteWorld, worldNumber: result.WorldRoute} + } + if !result.Completed { + return nil + } + if options.once { + a.printNextRecommendation(item, player) + return nil + } + next, found := a.nextOnRoute(route, item, player) + if !found { + a.printRouteFinished(route, item, player) + return nil + } + a.announceWorldTransition(item, next, player, wasComplete) + fmt.Fprintf(a.out, "\n%s\n", a.style.Accent(fmt.Sprintf("→ Continuing to Mission %02d: %s", next.Number, next.Title))) + item = next + } +} + +func (a *App) parsePlayOptions(args []string) (playOptions, bool, error) { + flags := a.newFlagSet("play", func() { a.printPlayUsage(a.errOut) }) + track := flags.String("track", mission.TrackLinux, "play the linux or docker track") + worldNumber := flags.Int("world", 0, "start the next incomplete stage in a world") + once := flags.Bool("once", false, "return after one completed mission") + web := flags.Bool("web", false, "show mission guidance in a local browser companion") + if help, err := parseFlags(flags, args); help || err != nil { + return playOptions{}, help, err + } + if flags.NArg() > 1 { + return playOptions{}, false, fmt.Errorf("usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [--web] [MISSION]") + } + trackProvided, worldProvided := flagProvided(flags, "track"), flagProvided(flags, "world") + options := playOptions{ + track: strings.ToLower(strings.TrimSpace(*track)), + worldNumber: *worldNumber, + once: *once, + web: *web, + mission: flags.Arg(0), + } + if options.track != mission.TrackLinux && options.track != mission.TrackDocker { + return playOptions{}, false, fmt.Errorf("unknown track %q; use linux or docker", *track) + } + if worldProvided && options.worldNumber < 1 { + return playOptions{}, false, fmt.Errorf("world number must be positive") + } + if flags.NArg() == 1 && (worldProvided || trackProvided) { + return playOptions{}, false, fmt.Errorf("MISSION cannot be combined with --track or --world") + } + return options, false, nil +} + +// startingMission resolves where play begins and which route continuous play +// follows. It reports found=false without error when the track is complete. +func (a *App) startingMission(options playOptions, player profile.Profile) (mission.Mission, playRoute, bool, error) { + switch { + case options.mission != "": + item, found := a.catalog.Find(options.mission) + if !found { + return mission.Mission{}, playRoute{}, false, fmt.Errorf("mission %q not found; run 'opsquest list'", options.mission) + } + return item, playRoute{kind: playRouteSequential}, true, nil + case options.worldNumber > 0: + route := playRoute{kind: playRouteWorld, worldNumber: options.worldNumber} + if item, found := a.catalog.NextInWorld(options.track, options.worldNumber, player.IsComplete); found { + return item, route, true, nil + } + if world, exists := a.catalog.World(options.track, options.worldNumber); exists && len(world.Missions) > 0 { + fmt.Fprintf(a.out, "%s\n", a.style.Accent(fmt.Sprintf("World %d is complete; replaying Stage 1.", options.worldNumber))) + return world.Missions[0], route, true, nil + } + return mission.Mission{}, playRoute{}, false, fmt.Errorf("world %d does not exist in the %s track; run 'opsquest map --track %s'", options.worldNumber, options.track, options.track) + default: + item, found := a.catalog.NextInTrack(options.track, player.IsComplete) + if !found { + fmt.Fprintf(a.out, "%s\n", a.style.Success(fmt.Sprintf("%s track complete! Replay a mission or inspect other worlds with 'opsquest map'.", trackDisplayName(options.track)))) + return mission.Mission{}, playRoute{}, false, nil + } + return item, playRoute{kind: playRouteRecommended}, true, nil + } +} + +func (a *App) announceCompanion(companion Companion) { + fmt.Fprintln(a.out, a.style.Header("WEB MISSION COMPANION")) + fmt.Fprintf(a.out, "Open this one-time local URL in your browser:\n%s\n", a.style.Accent(companion.URL())) + fmt.Fprintln(a.out, a.style.Muted("Keep this terminal for commands. The browser can display progress but cannot execute anything.")) +} + +func closeCompanion(companion Companion) error { + closeContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := companion.Close(closeContext); err != nil { + return fmt.Errorf("close web companion: %w", err) + } + return nil +} + +// onboard shows the quick start once, before a new player's first Linux +// mission, and records that it was shown. +func (a *App) onboard(item mission.Mission, player *profile.Profile, web bool) error { + if item.EffectiveTrack() != mission.TrackLinux || !isPristineProfile(*player) { + return nil + } + if !web { + a.printQuickStart() + } + player.Onboarded = true + return a.store.Save(*player) +} + +// newSession builds one mission attempt. Its mission list reads the live +// player, so in-lab maps reflect progress made earlier in the same run. +func (a *App) newSession(item mission.Mission, player *profile.Profile, reader game.CommandLineReader, companion Companion) *game.Session { + currentTrack := item.EffectiveTrack() + return &game.Session{ + Mission: item, + Player: player, + Saver: a.store, + Reporter: companion, + Companion: companion != nil, + In: a.in, + Out: a.out, + ErrOut: a.errOut, + Reader: reader, + Catalog: a.catalog, + ListMissions: func(args []string) error { + if !hasFlag(args, "--track") { + args = append([]string{"--track", currentTrack}, args...) + } + return a.listMissions(args, *player, true) + }, + Context: a.ctx, + Factory: a.factory, + Style: a.style, + ErrorStyle: a.errorStyle, + } +} + +// announceWorldTransition celebrates a world the player just finished for +// the first time before continuous play moves into the next world. +func (a *App) announceWorldTransition(completed, next mission.Mission, player profile.Profile, wasComplete bool) { + current, ok := a.catalog.Placement(completed.ID) + if !ok { + return + } + upcoming, ok := a.catalog.Placement(next.ID) + if !ok || upcoming.WorldNumber == current.WorldNumber || wasComplete || !a.worldComplete(current, player) { + return + } + fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("✓ World %d complete: %s", current.WorldNumber, current.WorldName))) + transition := "Entering" + if world, found := a.catalog.World(upcoming.Track, upcoming.WorldNumber); found { + for _, stage := range world.Missions { + if player.IsComplete(stage.ID) { + transition = "Continuing in" + break + } + } + } + fmt.Fprintln(a.out, a.style.World(fmt.Sprintf("%s World %d: %s", transition, upcoming.WorldNumber, upcoming.WorldName))) +} + +func (a *App) nextOnRoute(route playRoute, current mission.Mission, player profile.Profile) (mission.Mission, bool) { + switch route.kind { + case playRouteSequential: + return a.catalog.AdjacentInTrack(current.ID, 1) + case playRouteWorld: + next, found := a.catalog.AdjacentInTrack(current.ID, 1) + if !found { + return mission.Mission{}, false + } + placement, found := a.catalog.Placement(next.ID) + if !found || placement.Track != current.EffectiveTrack() || placement.WorldNumber != route.worldNumber { + return mission.Mission{}, false + } + return next, true + default: + return a.catalog.NextInTrack(current.EffectiveTrack(), player.IsComplete) + } +} + +func (a *App) printRouteFinished(route playRoute, current mission.Mission, player profile.Profile) { + if route.kind == playRouteWorld { + if placement, found := a.catalog.Placement(current.ID); found && a.worldComplete(placement, player) { + fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("World %d complete: %s!", placement.WorldNumber, placement.WorldName))) + return + } + fmt.Fprintf(a.out, "\n%s\n", a.style.Accent(fmt.Sprintf("Reached the end of World %d; unfinished stages remain. Resume with 'opsquest play --world %d'.", route.worldNumber, route.worldNumber))) + return + } + + if _, remaining := a.catalog.NextInTrack(current.EffectiveTrack(), player.IsComplete); remaining { + fmt.Fprintf(a.out, "\n%s\n", a.style.Accent("Reached the end of the selected route. Resume unfinished missions with 'opsquest play'.")) + return + } + fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("%s track complete!", trackDisplayName(current.EffectiveTrack())))) +} + +func (a *App) worldComplete(placement mission.Placement, player profile.Profile) bool { + world, found := a.catalog.World(placement.Track, placement.WorldNumber) + return found && completedMissions(world.Missions, player) == len(world.Missions) +} + +func (a *App) printNextRecommendation(completed mission.Mission, player profile.Profile) { + next, found := a.catalog.NextInTrack(completed.EffectiveTrack(), player.IsComplete) + if !found { + fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("%s track complete!", trackDisplayName(completed.EffectiveTrack())))) + return + } + placement, _ := a.catalog.Placement(next.ID) + fmt.Fprintf(a.out, "\n%s\n", a.style.Section("NEXT RECOMMENDED")) + fmt.Fprintf(a.out, "Mission %02d: %s · World %d, Stage %d/%d\n", next.Number, next.Title, placement.WorldNumber, placement.StageNumber, placement.StageTotal) + fmt.Fprintf(a.out, "Continue with %s, or jump anywhere with %s.\n", a.style.Accent("opsquest play"), a.style.Accent("opsquest map")) +} diff --git a/internal/cli/play_test.go b/internal/cli/play_test.go new file mode 100644 index 0000000..e85d382 --- /dev/null +++ b/internal/cli/play_test.go @@ -0,0 +1,390 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func TestPlayAwardsHintAdjustedXPAndPersistsProgress(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "hint\npwd\n", store) + if err := app.Run([]string{"play", "01"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "✓ Mission complete!") || !strings.Contains(out.String(), "+30 XP") { + t.Fatalf("output did not show adjusted completion:\n%s", out.String()) + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if player.XP != 30 || !player.IsComplete("linux-orientation") || player.Commands["pwd"] != 1 { + t.Fatalf("profile = %#v", player) + } +} + +func TestReplayDoesNotAwardXPAgain(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + first, _, _ := testApp(t, "pwd\n", store) + if err := first.Run([]string{"play", "1"}); err != nil { + t.Fatal(err) + } + second, out, _ := testApp(t, "pwd\n", store) + if err := second.Run([]string{"play", "linux-orientation"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "XP was already claimed") { + t.Fatalf("replay output = %s", out.String()) + } + if !strings.Contains(out.String(), "Reward: already claimed · 40 XP base") { + t.Fatalf("replay mission advertised claimable XP:\n%s", out.String()) + } + player, _ := store.Load() + if player.XP != 40 { + t.Fatalf("XP after replay = %d, want 40", player.XP) + } + preview, previewOut, _ := testApp(t, "", store) + if err := preview.Run([]string{"show", "1"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(previewOut.String(), "Reward: claimed · 40 XP earned") || !strings.Contains(previewOut.String(), "Hints: 0 used on first completion") { + t.Fatalf("completed preview advertised a fresh reward or hint budget:\n%s", previewOut.String()) + } +} + +func TestBarePlayContinuesThroughIncompleteMissions(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + input := strings.Join([]string{ + "pwd", + "cd /srv/web/config/live", + "quit", + }, "\n") + "\n" + app, out, errOut := testApp(t, input, store) + if err := app.Run([]string{"play"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + + output := out.String() + for _, expected := range []string{ + "✓ Mission complete!", + "Continuing to Mission 02: Configuration Crawl", + "Continuing to Mission 03: Read the Handoff", + "Mission paused", + } { + if !strings.Contains(output, expected) { + t.Errorf("continuous play output missing %q:\n%s", expected, output) + } + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if !player.IsComplete("linux-orientation") || !player.IsComplete("linux-config-crawl") { + t.Fatalf("completed missions = %#v", player.Completed) + } + if player.IsComplete("linux-read-handoff") || player.XP != 85 { + t.Fatalf("player after continuous play = %#v", player) + } +} + +func TestSelectedMissionContinuesAfterCompletion(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "pwd\nquit\n", store) + if err := app.Run([]string{"play", "1"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{"Continuing to Mission 02: Configuration Crawl", "MISSION 02", "Mission paused"} { + if !strings.Contains(out.String(), expected) { + t.Fatalf("selected mission did not continue with %q:\n%s", expected, out.String()) + } + } + if strings.Contains(out.String(), "NEXT RECOMMENDED") { + t.Fatalf("continuous mission unexpectedly returned a one-shot recommendation:\n%s", out.String()) + } +} + +func TestSelectedMissionReplayContinuesWithoutRepeatingWorldCompletion(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + seedCompletedMissions(t, store, linuxMissionIDsThroughArchive...) + + app, out, errOut := testApp(t, "pwd\nquit\n", store) + if err := app.Run([]string{"play", "1"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{"Replay complete", "Continuing to Mission 02: Configuration Crawl", "MISSION 02"} { + if !strings.Contains(out.String(), expected) { + t.Fatalf("selected replay did not continue with %q:\n%s", expected, out.String()) + } + } + if strings.Contains(out.String(), "World 1 complete") { + t.Fatalf("replay repeated an already-earned world completion:\n%s", out.String()) + } + if strings.Contains(out.String(), "Continuing to Mission 12") { + t.Fatalf("selected replay snapped back to global progress:\n%s", out.String()) + } +} + +func TestInMissionIDJumpFollowsTheSelectedSequence(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + seedCompletedMissions(t, store, linuxMissionIDsThroughArchive...) + + app, out, errOut := testApp(t, "play linux-orientation\npwd\nquit\n", store) + if err := app.Run([]string{"play", "10"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + output := out.String() + switchIndex := strings.Index(output, "Switching to Mission 01") + if switchIndex < 0 { + t.Fatalf("mission jump was not shown:\n%s", output) + } + afterJump := output[switchIndex:] + for _, expected := range []string{"MISSION 01: Where Am I?", "Replay complete", "Continuing to Mission 02: Configuration Crawl", "MISSION 02"} { + if !strings.Contains(afterJump, expected) { + t.Fatalf("selected sequence missing %q:\n%s", expected, afterJump) + } + } + if strings.Contains(afterJump, "Continuing to Mission 12") { + t.Fatalf("in-mission jump snapped back to global progress:\n%s", afterJump) + } +} + +func TestPlayOnceReturnsAfterSelectedMission(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "pwd\n", store) + if err := app.Run([]string{"play", "--once", "1"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + if strings.Contains(out.String(), "MISSION 02") { + t.Fatalf("--once unexpectedly continued:\n%s", out.String()) + } + if !strings.Contains(out.String(), "NEXT RECOMMENDED") || !strings.Contains(out.String(), "Mission 02: Configuration Crawl") { + t.Fatalf("--once did not explain the next step:\n%s", out.String()) + } +} + +func TestPlayCanJumpToAndCompleteOneWorld(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "quit\n", store) + if err := app.Run([]string{"play", "--world", "2"}); err != nil { + t.Fatalf("world jump error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "MISSION 07: Permission to Deploy") || !strings.Contains(out.String(), "World 2/4") { + t.Fatalf("world jump started the wrong stage:\n%s", out.String()) + } + + commands := strings.Join([]string{ + "pwd", + "cd /srv/web/config/live", + "cat handoff.txt", + "mkdir -p reports/daily", + "touch reports/daily/summary.txt", + `find . -name "*.log" -exec grep -l "ERROR" {} \;`, + "mv incident-104.txt /archive/2026/incident-104.txt", + }, "\n") + "\n" + app, out, errOut = testApp(t, commands, profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex")) + if err := app.Run([]string{"play", "--world", "1"}); err != nil { + t.Fatalf("world play error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "World 1 complete: First Day!") || strings.Contains(out.String(), "MISSION 07") { + t.Fatalf("world play did not stop at its boundary:\n%s", out.String()) + } + + replayStore := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + seedCompletedMissions(t, replayStore, linuxMissionIDsThroughArchive[:6]...) + app, out, errOut = testApp(t, commands, replayStore) + if err := app.Run([]string{"play", "--world", "1"}); err != nil { + t.Fatalf("completed world replay error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{ + "World 1 is complete; replaying Stage 1", + "MISSION 02: Configuration Crawl", + "MISSION 03: Read the Handoff", + "MISSION 04: A Place for Everything", + "MISSION 05: The Missing Log File", + "MISSION 06: The Release Shuffle", + "World 1 complete: First Day!", + } { + if !strings.Contains(out.String(), expected) { + t.Fatalf("completed world replay missing %q:\n%s", expected, out.String()) + } + } + if strings.Contains(out.String(), "MISSION 07") { + t.Fatalf("completed world replay crossed its boundary:\n%s", out.String()) + } + + app, _, _ = testApp(t, "", store) + if err := app.Run([]string{"play", "--world", "99"}); err == nil || !strings.Contains(err.Error(), "world 99 does not exist") { + t.Fatalf("invalid world error = %v", err) + } + for _, test := range []struct { + args []string + want string + }{ + {args: []string{"play", "--world", "0"}, want: "world number must be positive"}, + {args: []string{"play", "--track", "mainframe"}, want: "use linux or docker"}, + {args: []string{"play", "--world", "1", "3"}, want: "MISSION cannot be combined"}, + } { + app, _, _ = testApp(t, "", store) + if err := app.Run(test.args); err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("Run(%v) error = %v, want %q", test.args, err, test.want) + } + } +} + +func TestHintPenaltySurvivesAPausedAttempt(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + paused, _, _ := testApp(t, "hint\nquit\n", store) + if err := paused.Run([]string{"play", "1"}); err != nil { + t.Fatal(err) + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if player.MissionHints("linux-orientation") != 1 { + t.Fatalf("saved hint progress = %d, want 1", player.MissionHints("linux-orientation")) + } + preview, previewOut, previewErr := testApp(t, "", store) + if err := preview.Run([]string{"show", "1"}); err != nil { + t.Fatalf("show hinted mission error = %v; stderr = %s", err, previewErr.String()) + } + if !strings.Contains(previewOut.String(), "Reward: 30 XP") || !strings.Contains(previewOut.String(), "Hints: 1 used · 1 remaining · 2 total") { + t.Fatalf("show did not reflect persisted hint state:\n%s", previewOut.String()) + } + profileApp, profileOut, profileErr := testApp(t, "", store) + if err := profileApp.Run([]string{"profile"}); err != nil { + t.Fatalf("profile with active hint error = %v; stderr = %s", err, profileErr.String()) + } + if !strings.Contains(profileOut.String(), "Hints used: 1 (completed: 0 · active: 1)") { + t.Fatalf("profile hid active hint penalties:\n%s", profileOut.String()) + } + + resumed, out, _ := testApp(t, "pwd\n", store) + if err := resumed.Run([]string{"play", "1"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "+30 XP") { + t.Fatalf("resumed output did not retain hint penalty:\n%s", out.String()) + } + player, _ = store.Load() + if player.MissionHints("linux-orientation") != 0 { + t.Fatalf("hint progress was not cleared after completion") + } +} + +func TestHintReportsTheActualFloorAdjustedCost(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "hint\nhint\nhint\nhint\nhint\nquit\n", store) + if err := app.Run([]string{"play", "12"}); err != nil { + t.Fatalf("hinted play error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "Hint 5/5 (-14 XP):") { + t.Fatalf("last hint did not report the reward floor's actual cost:\n%s", out.String()) + } +} + +func TestReplayHintsAdvanceWithoutChangingProgress(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + player := profile.New("alex") + completedAt := time.Unix(1, 0) + player.Complete("linux-find-logs", 75, 0, completedAt) + if err := store.Save(player); err != nil { + t.Fatal(err) + } + + app, out, errOut := testApp(t, "hint\nhint\nhint\nhint\nquit\n", store) + if err := app.Run([]string{"play", "5"}); err != nil { + t.Fatalf("replay hints error = %v; stderr = %s", err, errOut.String()) + } + for _, expected := range []string{ + "Hint 1/3 (no XP cost):", + "Hint 2/3 (no XP cost):", + "Hint 3/3 (no XP cost):", + "No more hints.", + } { + if count := strings.Count(out.String(), expected); count != 1 { + t.Fatalf("replay output contains %q %d times, want once:\n%s", expected, count, out.String()) + } + } + + loaded, err := store.Load() + if err != nil { + t.Fatal(err) + } + completion := loaded.Completed["linux-find-logs"] + if loaded.XP != 75 || completion.XP != 75 || !completion.CompletedAt.Equal(completedAt) { + t.Fatalf("replay hints changed completion progress: %#v", loaded) + } + if got := loaded.MissionHints("linux-find-logs"); got != 0 { + t.Fatalf("replay hint progress persisted as %d, want 0", got) + } +} + +func TestMissionStatusAndRestart(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + input := strings.Join([]string{ + "mkdir -p reports/daily", + "status", + "restart", + "status", + "mkdir -p reports/daily", + "touch reports/daily/summary.txt", + }, "\n") + "\n" + app, out, errOut := testApp(t, input, store) + if err := app.Run([]string{"play", "4"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "Outcome checks satisfied: 1/2") || !strings.Contains(out.String(), "Outcome checks satisfied: 0/2") { + t.Fatalf("status output = %s", out.String()) + } + if !strings.Contains(out.String(), "Mission environment restarted") || !strings.Contains(out.String(), "✓ Mission complete!") { + t.Fatalf("restart output = %s", out.String()) + } +} + +func TestMissionStatusExplainsWrongPathWithoutRequiringCommandOrder(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + input := strings.Join([]string{ + "mkdir -p /workspace/reports/daily", + "touch summary.txt", + "status", + "quit", + }, "\n") + "\n" + app, out, errOut := testApp(t, input, store) + if err := app.Run([]string{"play", "4"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + output := out.String() + for _, expected := range []string{ + "Progress — 1/2 outcome checks satisfied", + "✓ Directory exists: /workspace/reports/daily", + "○ File exists: /workspace/reports/daily/summary.txt", + } { + if !strings.Contains(output, expected) { + t.Errorf("status output missing %q:\n%s", expected, output) + } + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if player.IsComplete("linux-workspace") { + t.Fatal("wrong-path file unexpectedly completed the mission") + } +} + +func TestWorkspaceAcceptsChangingDirectoryBeforeCreatingFile(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + input := "mkdir -p reports/daily\ncd reports/daily\ntouch summary.txt\n" + app, out, errOut := testApp(t, input, store) + if err := app.Run([]string{"play", "4"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "✓ Mission complete!") { + t.Fatalf("alternative solution output:\n%s", out.String()) + } +} diff --git a/internal/cli/profile.go b/internal/cli/profile.go new file mode 100644 index 0000000..0b04517 --- /dev/null +++ b/internal/cli/profile.go @@ -0,0 +1,140 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func (a *App) runProfile(args []string) error { + flags := a.newFlagSet("profile", func() { a.printProfileUsage(a.errOut) }) + name := flags.String("name", "", "update the operator display name") + if help, err := parseFlags(flags, args); help || err != nil { + return err + } + if flags.NArg() > 0 { + return fmt.Errorf("profile does not accept positional arguments") + } + nameProvided := flagProvided(flags, "name") + player, err := a.loadPlayer() + if err != nil { + return err + } + if nameProvided { + if err := profile.ValidateName(*name); err != nil { + return err + } + player.Name = strings.TrimSpace(*name) + if err := a.store.Save(player); err != nil { + return err + } + fmt.Fprintln(a.out, a.style.Success("Profile name updated.")) + } + completed := completedMissions(a.catalog.All(), player) + type worldProgress struct { + name string + track string + number int + completed int + total int + } + progress := make([]worldProgress, 0) + for _, track := range []string{mission.TrackLinux, mission.TrackDocker} { + for _, world := range a.catalog.Worlds(track) { + item := worldProgress{name: world.Name, track: track, number: world.Number, completed: completedMissions(world.Missions, player), total: len(world.Missions)} + progress = append(progress, item) + } + } + linuxMissions, dockerMissions := a.catalog.InTrack(mission.TrackLinux), a.catalog.InTrack(mission.TrackDocker) + linuxCompleted, linuxTotal := completedMissions(linuxMissions, player), len(linuxMissions) + dockerCompleted, dockerTotal := completedMissions(dockerMissions, player), len(dockerMissions) + fmt.Fprintln(a.out, a.style.Header("PROFILE")) + fmt.Fprintln(a.out) + fmt.Fprintf(a.out, "%s %s\n", a.style.Accent("Operator:"), player.Name) + fmt.Fprintf(a.out, "%s %s\n", a.style.Accent("Rank:"), player.Rank()) + fmt.Fprintf(a.out, "%s %d · %s\n", a.style.Accent("Level:"), player.Level(), a.style.Reward(fmt.Sprintf("%d XP", player.XP))) + if nextRank, needed, exists := player.NextRank(); exists { + fmt.Fprintf(a.out, "%s %s in %s\n", a.style.Muted("Next rank:"), nextRank, a.style.Reward(fmt.Sprintf("%d XP", needed))) + } + fmt.Fprintln(a.out) + labelWidth := 0 + for _, world := range progress { + label := fmt.Sprintf("World %d · %s", world.number, world.name) + labelWidth = max(labelWidth, len(label)) + } + printWorlds := func(track string) { + for _, world := range progress { + if world.track != track { + continue + } + label := fmt.Sprintf("World %d · %s", world.number, world.name) + fmt.Fprintf(a.out, " %s %s %3d%%\n", a.style.World(fmt.Sprintf("%-*s", labelWidth, label)), styledProgressBar(a.style, world.completed, world.total, 10), percentage(world.completed, world.total)) + } + } + fmt.Fprintf(a.out, "%s %s %3d%%\n", a.style.Section("Linux"), styledProgressBar(a.style, linuxCompleted, linuxTotal, 20), percentage(linuxCompleted, linuxTotal)) + printWorlds(mission.TrackLinux) + dockerState := "readiness: run opsquest doctor" + fmt.Fprintf(a.out, "%s %s %3d%% %s\n", a.style.Section("Docker"), styledProgressBar(a.style, dockerCompleted, dockerTotal, 20), percentage(dockerCompleted, dockerTotal), dockerState) + printWorlds(mission.TrackDocker) + fmt.Fprintf(a.out, "%s\n\n", a.style.Muted(fmt.Sprintf("K8s %s locked", progressBar(0, 20, 20)))) + fmt.Fprintf(a.out, "Commands mastered: %d\n", len(player.Commands)) + fmt.Fprintf(a.out, "Missions completed: %d\n", completed) + completedHints, activeHints := player.HintsUsed(), player.ActiveHints() + fmt.Fprintf(a.out, "Hints used: %d (completed: %d · active: %d)\n", completedHints+activeHints, completedHints, activeHints) + fmt.Fprintf(a.out, "Achievements: %d/%d\n", player.AchievementCount(), len(profile.AchievementDefinitions())) + return nil +} + +func (a *App) runCommands(args []string) error { + flags := a.newFlagSet("commands", func() { fmt.Fprintln(a.errOut, "Usage: opsquest commands") }) + if help, err := parseFlags(flags, args); help || err != nil { + return err + } + if flags.NArg() > 0 { + return fmt.Errorf("commands does not accept positional arguments") + } + player, err := a.loadPlayer() + if err != nil { + return err + } + commands := player.MasteredCommands() + if len(commands) == 0 { + fmt.Fprintln(a.out, "No commands mastered yet. Start with 'opsquest play'.") + return nil + } + fmt.Fprintf(a.out, "%s\n\n", a.style.Header(fmt.Sprintf("COMMAND MASTERY (%d)", len(commands)))) + for _, command := range commands { + name := fmt.Sprintf("%-12s", command) + fmt.Fprintf(a.out, " %s used successfully %d %s\n", a.style.Accent(name), player.Commands[command], plural(player.Commands[command], "time", "times")) + } + return nil +} + +func (a *App) runAchievements(args []string) error { + flags := a.newFlagSet("achievements", func() { fmt.Fprintln(a.errOut, "Usage: opsquest achievements") }) + if help, err := parseFlags(flags, args); help || err != nil { + return err + } + if flags.NArg() > 0 { + return fmt.Errorf("achievements does not accept positional arguments") + } + player, err := a.loadPlayer() + if err != nil { + return err + } + definitions := profile.AchievementDefinitions() + fmt.Fprintln(a.out, a.style.Header("ACHIEVEMENTS")) + for _, achievement := range definitions { + unlockedAt, unlocked := player.Unlocked[achievement.ID] + if unlocked { + title := fmt.Sprintf("%-22s", achievement.Title) + fmt.Fprintf(a.out, " %s %s %s %s\n", a.style.Achievement("★"), a.style.Achievement(title), achievement.Description, a.style.Muted("["+unlockedAt.Local().Format("2006-01-02")+"]")) + } else { + fmt.Fprintf(a.out, " %s %-22s %s\n", a.style.Muted("☆"), achievement.Title, achievement.Description) + } + } + fmt.Fprintf(a.out, "\n%d/%d unlocked\n", player.AchievementCount(), len(definitions)) + return nil +} diff --git a/internal/cli/profile_test.go b/internal/cli/profile_test.go new file mode 100644 index 0000000..a1d3ddb --- /dev/null +++ b/internal/cli/profile_test.go @@ -0,0 +1,135 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func TestProfileRenameShowAndDoctor(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, _ := testApp(t, "", store) + if err := app.Run([]string{"profile", "--name", "casey"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Operator: casey") { + t.Fatalf("profile output = %s", out.String()) + } + + app, out, _ = testApp(t, "", store) + if err := app.Run([]string{"show", "19"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Production Friday") || !strings.Contains(out.String(), "Reward: 160 XP") || + !strings.Contains(out.String(), "Commands you may need to solve this level:") || + !strings.Contains(out.String(), " tar, sed, vi, chmod, ps, kill") { + t.Fatalf("show output = %s", out.String()) + } + + app, out, _ = testApp(t, "", store) + if err := app.Run([]string{"doctor"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "39 missions (23 Linux, 16 Docker)") || !strings.Contains(out.String(), "Linux labs: in-memory; no host shell or filesystem access") { + t.Fatalf("doctor output = %s", out.String()) + } +} + +func TestProfileRenameRejectsTerminalControlsWithoutChangingTheSave(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + player := profile.New("alex") + if err := store.Save(player); err != nil { + t.Fatal(err) + } + app, _, _ := testApp(t, "", store) + err := app.Run([]string{"profile", "--name", "casey\x1b[2J"}) + if err == nil || !strings.Contains(err.Error(), "control or non-printable") { + t.Fatalf("control-bearing name error = %v", err) + } + loaded, loadErr := store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + if loaded.Name != "alex" { + t.Fatalf("rejected rename changed profile name to %q", loaded.Name) + } +} + +func TestPipelineAndBossAchievementsUnlock(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + command := "grep ERROR incidents.log | awk '{print $3}' | sort | uniq > /reports/error-services.txt\n" + app, out, errOut := testApp(t, command, store) + if err := app.Run([]string{"play", "12"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"first-fix", "pipe-dream", "boss-slayer"} { + if !player.HasAchievement(id) { + t.Errorf("achievement %s was not unlocked", id) + } + } + if !strings.Contains(out.String(), "Achievement unlocked: Pipe Dream") { + t.Fatalf("completion output = %s", out.String()) + } + + app, out, _ = testApp(t, "", store) + if err := app.Run([]string{"achievements"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "3/6 unlocked") { + t.Fatalf("achievements output = %s", out.String()) + } +} + +func TestCompletionistRequiresEveryCurrentCatalogMission(t *testing.T) { + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + items := catalog.InTrack(mission.TrackLinux) + completedAt := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + seedFrom int + wantUnlock bool + }{ + {name: "unknown ID cannot replace missing mission", seedFrom: 2, wantUnlock: false}, + {name: "all current missions still unlock", seedFrom: 1, wantUnlock: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + player := profile.New("alex") + for _, item := range items[test.seedFrom:] { + player.Complete(item.ID, 0, 0, completedAt) + } + player.Complete("retired-linux-mission", 0, 0, completedAt) + if err := store.Save(player); err != nil { + t.Fatal(err) + } + + app, out, errOut := testApp(t, "pwd\n", store) + if err := app.Run([]string{"play", "1"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + player, err = store.Load() + if err != nil { + t.Fatal(err) + } + if got := player.HasAchievement("linux-completionist"); got != test.wantUnlock { + t.Fatalf("completionist unlocked = %v, want %v; completed = %#v", got, test.wantUnlock, player.Completed) + } + if announced := strings.Contains(out.String(), "Achievement unlocked: Linux Completionist"); announced != test.wantUnlock { + t.Fatalf("completionist announcement = %v, want %v; output:\n%s", announced, test.wantUnlock, out.String()) + } + }) + } +} diff --git a/internal/cli/reset.go b/internal/cli/reset.go new file mode 100644 index 0000000..9e813d1 --- /dev/null +++ b/internal/cli/reset.go @@ -0,0 +1,42 @@ +package cli + +import ( + "bufio" + "fmt" + "io" + "strings" +) + +func (a *App) runReset(args []string) error { + flags := a.newFlagSet("reset", func() { fmt.Fprintln(a.errOut, "Usage: opsquest reset [--yes]") }) + yes := flags.Bool("yes", false, "reset without confirmation") + if help, err := parseFlags(flags, args); help || err != nil { + return err + } + if flags.NArg() > 0 { + return fmt.Errorf("reset does not accept positional arguments") + } + if !*yes { + fmt.Fprint(a.out, "Reset all OpsQuest progress? [y/N] ") + reader := bufio.NewReader(a.in) + answer, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return fmt.Errorf("read confirmation: %w", err) + } + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer != "y" && answer != "yes" { + fmt.Fprintln(a.out, "Reset cancelled.") + return nil + } + } + removed, err := a.store.Reset() + if err != nil { + return err + } + if removed { + fmt.Fprintln(a.out, a.style.Success("Progress reset. Welcome back, Intern.")) + } else { + fmt.Fprintln(a.out, a.style.Muted("No saved progress found.")) + } + return nil +} diff --git a/internal/cli/show.go b/internal/cli/show.go new file mode 100644 index 0000000..60f8c3f --- /dev/null +++ b/internal/cli/show.go @@ -0,0 +1,80 @@ +package cli + +import ( + "fmt" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func (a *App) runShow(args []string) error { + flags := a.newFlagSet("show", func() { fmt.Fprintln(a.errOut, "Usage: opsquest show [MISSION]") }) + if help, err := parseFlags(flags, args); help || err != nil { + return err + } + if flags.NArg() > 1 { + return fmt.Errorf("usage: opsquest show [MISSION]") + } + player, err := a.loadPlayer() + if err != nil { + return err + } + var item mission.Mission + var found bool + if flags.NArg() == 1 { + item, found = a.catalog.Find(flags.Arg(0)) + if !found { + return fmt.Errorf("mission %q not found; run 'opsquest list'", flags.Arg(0)) + } + } else { + item, found = a.catalog.NextInTrack(mission.TrackLinux, player.IsComplete) + if !found { + item, found = a.catalog.LastInTrack(mission.TrackLinux) + } + } + if !found { + return fmt.Errorf("no missions are available") + } + completion, complete := player.Completed[item.ID] + status := "not completed" + if complete { + status = "completed" + } + hintsUsed := player.MissionHints(item.ID) + fmt.Fprintln(a.out, a.style.Header(fmt.Sprintf("MISSION %02d: %s", item.Number, item.Title))) + if placement, exists := a.catalog.Placement(item.ID); exists { + fmt.Fprintf(a.out, "Track: %s · World %d/%d: %s · Stage %d/%d\n", + trackDisplayName(placement.Track), placement.WorldNumber, placement.WorldTotal, + a.style.World(placement.WorldName), placement.StageNumber, placement.StageTotal) + } else { + fmt.Fprintf(a.out, "Track: %s · Campaign: %s\n", trackDisplayName(item.EffectiveTrack()), a.style.World(item.Campaign)) + } + reward := a.style.Reward(fmt.Sprintf("%d XP", game.AdjustedReward(item, hintsUsed))) + if complete { + reward = a.style.Accent(fmt.Sprintf("claimed · %d XP earned", completion.XP)) + } + fmt.Fprintf(a.out, "Difficulty: %s · Reward: %s\n", a.style.Difficulty(item.Difficulty), reward) + styledStatus := a.style.Accent(status) + if complete { + styledStatus = a.style.Success(status) + } + remainingHints := max(len(item.Hints)-hintsUsed, 0) + hintStatus := fmt.Sprintf("%d used · %d remaining · %d total", hintsUsed, remainingHints, len(item.Hints)) + if complete { + hintStatus = fmt.Sprintf("%d used on first completion · replay hints do not change XP", completion.HintsUsed) + } + fmt.Fprintf(a.out, "Status: %s · Outcome checks: %d · Hints: %s\n\n", styledStatus, len(item.Validation.All), hintStatus) + fmt.Fprintf(a.out, "%s\n%s\n\n", a.style.Section("INCIDENT"), item.Story) + fmt.Fprintf(a.out, "%s\n%s\n\n%s\n", a.style.Section("OBJECTIVE"), item.Objective, a.style.CommandGuide(item.SuggestedCommands)) + if item.EffectiveEnvironment() == mission.EnvironmentDocker { + availability := game.EnvironmentAvailability(a.ctx, a.factory, item) + fmt.Fprintln(a.out) + if availability.Available { + fmt.Fprintln(a.out, a.style.Success("Docker lab ready.")) + } else { + fmt.Fprintln(a.out, a.style.Warning(availability.Detail)) + } + } + fmt.Fprintf(a.out, "\nPlay with: opsquest play %d\n", item.Number) + return nil +} diff --git a/internal/cli/web_play_test.go b/internal/cli/web_play_test.go new file mode 100644 index 0000000..e6bb1c1 --- /dev/null +++ b/internal/cli/web_play_test.go @@ -0,0 +1,100 @@ +package cli + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func TestWebPlayUsesCompanionForMissionPresentationAndLiveProgress(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "hint\npwd\n", store) + companion := &testWebCompanion{} + app.startCompanion = func(context.Context) (Companion, error) { return companion, nil } + + if err := app.Run([]string{"play", "--web", "--once", "1"}); err != nil { + t.Fatalf("web play error = %v; stderr = %s", err, errOut.String()) + } + if companion.closeCount != 1 { + t.Fatalf("companion close count = %d, want 1", companion.closeCount) + } + if len(companion.events) != 4 || companion.events[0].Type != game.AttemptStarted || companion.events[1].Type != game.AttemptHint || companion.events[3].Type != game.AttemptCompleted { + t.Fatalf("companion event types = %#v", eventTypesForCLI(companion.events)) + } + output := out.String() + for _, visible := range []string{ + "WEB MISSION COMPANION", + companion.URL(), + "Keep this terminal for commands", + "Mission 01 ready in the web companion", + "Hint 1/2 revealed in the web companion", + "/home/operator", + "✓ Mission complete!", + } { + if !strings.Contains(output, visible) { + t.Errorf("web play output missing %q:\n%s", visible, output) + } + } + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("1") + for _, hidden := range []string{item.Story, item.Objective, item.Hints[0], item.Explanation, "FIRST SHIFT BRIEFING", "Commands you may need to solve this level:"} { + if strings.Contains(output, hidden) { + t.Errorf("web play terminal leaked companion content %q:\n%s", hidden, output) + } + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if player.XP != 30 || !player.IsComplete(item.ID) || !player.Onboarded { + t.Fatalf("web play profile = %#v", player) + } +} + +func TestWebPlayFailsBeforeOnboardingWhenCompanionCannotStart(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, _ := testApp(t, "", store) + app.startCompanion = func(context.Context) (Companion, error) { + return nil, fmt.Errorf("loopback unavailable") + } + + err := app.Run([]string{"play", "--web", "1"}) + if err == nil || !strings.Contains(err.Error(), "start web companion: loopback unavailable") { + t.Fatalf("web companion start error = %v", err) + } + player, loadErr := store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + if player.Onboarded || len(player.Completed) != 0 || player.XP != 0 { + t.Fatalf("failed companion start changed profile = %#v", player) + } +} + +func TestWebPlayReportsCompanionCloseFailureAfterPersistingCompletion(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, _ := testApp(t, "pwd\n", store) + companion := &testWebCompanion{closeErr: fmt.Errorf("shutdown stalled")} + app.startCompanion = func(context.Context) (Companion, error) { return companion, nil } + + err := app.Run([]string{"play", "--web", "--once", "1"}) + if err == nil || !strings.Contains(err.Error(), "close web companion: shutdown stalled") { + t.Fatalf("web companion close error = %v", err) + } + player, loadErr := store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + if !player.IsComplete("linux-orientation") || player.XP != 40 { + t.Fatalf("completion was not persisted before companion close error: %#v", player) + } +} diff --git a/internal/dockerlab/availability.go b/internal/dockerlab/availability.go new file mode 100644 index 0000000..9ae60d9 --- /dev/null +++ b/internal/dockerlab/availability.go @@ -0,0 +1,100 @@ +package dockerlab + +import ( + "context" + "fmt" + "strings" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +// Availability checks only the Docker resources needed by item and never +// pulls an image or otherwise mutates the daemon. +func (f *Factory) Availability(ctx context.Context, item mission.Mission) game.Availability { + if ctx == nil { + ctx = context.Background() + } + if item.EffectiveEnvironment() != mission.EnvironmentDocker { + return game.EnvironmentAvailability(ctx, f.fallback, item) + } + if item.Docker == nil { + return game.Availability{Available: false, Detail: fmt.Sprintf("Docker mission %s has no Docker setup", item.ID)} + } + if err := mission.ValidateDockerSetup(*item.Docker); err != nil { + return game.Availability{Available: false, Detail: fmt.Sprintf("Docker mission %s is invalid: %v", item.ID, err)} + } + if f.lookupErr != nil || f.runner == nil { + return game.Availability{ + Available: false, + Detail: "Docker labs unavailable: docker executable not found in PATH; install Docker Engine, Docker Desktop, or OrbStack, then run 'opsquest doctor'. Linux missions remain available.", + } + } + if err := ctx.Err(); err != nil { + return game.Availability{Available: false, Detail: fmt.Sprintf("Docker availability check canceled: %v", err)} + } + runtime := f.detectDockerRuntime(ctx) + operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) + result, err := f.runner.run(operationCtx, "version", "--format", "{{.Server.Version}}") + cancel() + if err != nil || strings.TrimSpace(result.stdout) == "" { + detail := dockerFailureDetail(result, err) + instruction := "start Docker or OrbStack and check the active Docker context" + if runtime == dockerRuntimeOrbStack { + instruction = "start OrbStack and check the 'orbstack' Docker context" + } + return game.Availability{ + Available: false, + Detail: "Docker labs unavailable: the Docker-compatible engine could not be reached" + detail + "; " + instruction + ", then run 'opsquest doctor'. Linux missions remain available.", + } + } + seen := make(map[string]bool, len(item.Docker.Images)) + for _, image := range item.Docker.Images { + if seen[image.Reference] { + continue + } + seen[image.Reference] = true + operationCtx, cancel = context.WithTimeout(ctx, dockerOperationTimeout) + result, err = f.runner.run(operationCtx, "image", "inspect", "--format", "{{.Id}}", image.Reference) + cancel() + if err != nil || strings.TrimSpace(result.stdout) == "" { + pullCommand := "docker pull " + image.Reference + if runtime == dockerRuntimeOrbStack { + pullCommand = "DOCKER_CONTEXT=orbstack " + pullCommand + } + return game.Availability{ + Available: false, + Detail: fmt.Sprintf( + "Docker lab image %s is not available locally; run '%s' and try again. OpsQuest never pulls images automatically.", + image.Reference, + pullCommand, + ), + } + } + } + if runtime == dockerRuntimeOrbStack { + return game.Availability{Available: true, Detail: "OrbStack is ready for this mission through the 'orbstack' Docker context."} + } + return game.Availability{Available: true, Detail: "Docker is ready for this mission."} +} + +type dockerRuntime uint8 + +const ( + dockerRuntimeDefault dockerRuntime = iota + dockerRuntimeOrbStack +) + +// detectDockerRuntime identifies known Docker-compatible providers only when +// the Docker CLI reports their official context name. Detection is advisory: +// an older CLI or a broken local context store must not make an otherwise +// reachable Docker engine unavailable. +func (f *Factory) detectDockerRuntime(ctx context.Context) dockerRuntime { + operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) + defer cancel() + result, err := f.runner.run(operationCtx, "context", "show") + if err == nil && strings.TrimSpace(result.stdout) == orbStackContext { + return dockerRuntimeOrbStack + } + return dockerRuntimeDefault +} diff --git a/internal/dockerlab/cleanup.go b/internal/dockerlab/cleanup.go new file mode 100644 index 0000000..ae2295d --- /dev/null +++ b/internal/dockerlab/cleanup.go @@ -0,0 +1,112 @@ +package dockerlab + +import ( + "context" + "errors" + "fmt" +) + +func (e *environment) Close() error { + e.cleanupMutex.Lock() + defer e.cleanupMutex.Unlock() + + e.mutex.Lock() + if e.closed { + e.mutex.Unlock() + return nil + } + if !e.closing { + e.closing = true + e.cleanupPending = append([]*trackedContainer(nil), e.containers...) + e.networksPending = append([]*trackedNetwork(nil), e.networks...) + } + containers := append([]*trackedContainer(nil), e.cleanupPending...) + networks := append([]*trackedNetwork(nil), e.networksPending...) + e.mutex.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + var cleanupErrors []error + unresolved := make([]*trackedContainer, 0, len(containers)) + for index := len(containers) - 1; index >= 0; index-- { + tracked := containers[index] + inspection, exists, err := e.inspectTrackedForCleanup(ctx, tracked) + if err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("inspect Docker fixture %s before cleanup: %w", tracked.alias, err)) + unresolved = append(unresolved, tracked) + continue + } + if !exists { + continue + } + labels := inspection.Config.Labels + if !e.owns(labels, tracked.alias) { + cleanupErrors = append(cleanupErrors, fmt.Errorf("refusing to remove Docker fixture %s because its ownership labels do not match", tracked.alias)) + unresolved = append(unresolved, tracked) + continue + } + tracked.id = inspection.ID + if _, err := e.run(ctx, "container", "rm", "--force", inspection.ID); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("remove Docker fixture %s: %w", tracked.alias, err)) + unresolved = append(unresolved, tracked) + } + } + + // Networks go last: Docker refuses to remove a network while any of + // its containers still exist. + unresolvedNetworks, networkErrors := e.closeNetworks(ctx, networks) + cleanupErrors = append(cleanupErrors, networkErrors...) + + e.mutex.Lock() + e.cleanupPending = unresolved + e.networksPending = unresolvedNetworks + e.closed = len(unresolved) == 0 && len(unresolvedNetworks) == 0 + e.mutex.Unlock() + return errors.Join(cleanupErrors...) +} + +func (e *environment) inspectTrackedForCleanup(ctx context.Context, tracked *trackedContainer) (containerInspection, bool, error) { + if tracked.id != "" { + return e.inspectUnchecked(ctx, tracked.id) + } + inspection, exists, err := e.inspectReferenceUnchecked(ctx, tracked.actualName) + if err != nil || !exists { + return inspection, exists, err + } + if !containerIDPattern.MatchString(inspection.ID) { + return containerInspection{}, false, fmt.Errorf("Docker inspection returned an invalid container ID") + } + return inspection, true, nil +} + +// closeNetworks removes attempt networks after their containers, verifying +// ownership labels on each exact ID. Networks that cannot be resolved yet +// are returned for a later retry. +func (e *environment) closeNetworks(ctx context.Context, networks []*trackedNetwork) ([]*trackedNetwork, []error) { + var unresolved []*trackedNetwork + var cleanupErrors []error + for index := len(networks) - 1; index >= 0; index-- { + network := networks[index] + inspection, exists, err := e.inspectNetworkUnchecked(ctx, network) + if err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("inspect Docker network %s before cleanup: %w", network.name, err)) + unresolved = append(unresolved, network) + continue + } + if !exists { + continue + } + labels := inspection.Labels + if !e.owns(labels, network.name) || !containerIDPattern.MatchString(inspection.ID) { + cleanupErrors = append(cleanupErrors, fmt.Errorf("refusing to remove Docker network %s because its ownership labels do not match", network.name)) + unresolved = append(unresolved, network) + continue + } + network.id = inspection.ID + if result, err := e.run(ctx, "network", "rm", inspection.ID); err != nil && !isMissingNetwork(result, inspection.ID) { + cleanupErrors = append(cleanupErrors, fmt.Errorf("remove Docker network %s: %w", network.name, err)) + unresolved = append(unresolved, network) + } + } + return unresolved, cleanupErrors +} diff --git a/internal/dockerlab/cleanup_test.go b/internal/dockerlab/cleanup_test.go new file mode 100644 index 0000000..965a069 --- /dev/null +++ b/internal/dockerlab/cleanup_test.go @@ -0,0 +1,197 @@ +package dockerlab + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/game" +) + +func TestCloseVerifiesOwnershipAndIsIdempotent(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + apiID := environment.byAlias["api"].id + metricsID := environment.byAlias["metrics"].id + commandRunner.resetCalls() + + if err := environment.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + want := [][]string{ + {"container", "inspect", "--format", "{{json .}}", metricsID}, + {"container", "rm", "--force", metricsID}, + {"container", "inspect", "--format", "{{json .}}", apiID}, + {"container", "rm", "--force", apiID}, + } + if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { + t.Fatalf("cleanup calls:\n got: %q\nwant: %q", got, want) + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after Close = %d", commandRunner.containerCount()) + } + if err := environment.Close(); err != nil { + t.Fatalf("second Close() error = %v", err) + } + if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { + t.Fatalf("second Close issued more calls: %q", got) + } +} + +func TestCloseRetriesOnlyUnresolvedContainers(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + apiID := environment.byAlias["api"].id + metricsID := environment.byAlias["metrics"].id + transientFailure := errors.New("transient remove failure") + commandRunner.failRemoveOnce(metricsID, transientFailure) + commandRunner.resetCalls() + + firstErr := environment.Close() + if firstErr == nil || !strings.Contains(firstErr.Error(), transientFailure.Error()) { + t.Fatalf("first Close() error = %v, want transient failure", firstErr) + } + if commandRunner.containerCount() != 1 { + t.Fatalf("containers after failed Close = %d, want only unresolved fixture", commandRunner.containerCount()) + } + firstCalls := [][]string{ + {"container", "inspect", "--format", "{{json .}}", metricsID}, + {"container", "rm", "--force", metricsID}, + {"container", "inspect", "--format", "{{json .}}", apiID}, + {"container", "rm", "--force", apiID}, + } + if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, firstCalls) { + t.Fatalf("first cleanup calls:\n got: %q\nwant: %q", got, firstCalls) + } + if _, err := environment.Execute(context.Background(), "docker ps"); err == nil || !strings.Contains(err.Error(), "environment is closed") { + t.Fatalf("Execute() after failed cleanup error = %v, want closed environment", err) + } + + if err := environment.Close(); err != nil { + t.Fatalf("retry Close() error = %v", err) + } + want := append(firstCalls, + []string{"container", "inspect", "--format", "{{json .}}", metricsID}, + []string{"container", "rm", "--force", metricsID}, + ) + if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { + t.Fatalf("cleanup calls after retry:\n got: %q\nwant: %q", got, want) + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after retry = %d", commandRunner.containerCount()) + } + if err := environment.Close(); err != nil { + t.Fatalf("completed Close() error = %v", err) + } + if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { + t.Fatalf("completed Close issued more calls: %q", got) + } +} + +func TestCloseTreatsAlreadyMissingContainerAsResolved(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + apiID := environment.byAlias["api"].id + metricsID := environment.byAlias["metrics"].id + commandRunner.removeContainer(metricsID) + commandRunner.resetCalls() + + if err := environment.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + want := [][]string{ + {"container", "inspect", "--format", "{{json .}}", metricsID}, + {"container", "inspect", "--format", "{{json .}}", apiID}, + {"container", "rm", "--force", apiID}, + } + if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { + t.Fatalf("cleanup calls:\n got: %q\nwant: %q", got, want) + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after Close = %d", commandRunner.containerCount()) + } +} + +func TestCloseRefusesContainerWithMismatchedOwnership(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + apiID := environment.byAlias["api"].id + metricsID := environment.byAlias["metrics"].id + commandRunner.corruptLabel(apiID, sessionLabel, "another-session") + commandRunner.resetCalls() + for attempt := 1; attempt <= 2; attempt++ { + if err := environment.Close(); err == nil || !strings.Contains(err.Error(), "ownership labels do not match") { + t.Fatalf("Close() attempt %d error = %v", attempt, err) + } + } + commandRunner.mutex.Lock() + _, apiExists := commandRunner.containers[apiID] + _, metricsExists := commandRunner.containers[metricsID] + commandRunner.mutex.Unlock() + if !apiExists { + t.Fatal("mismatched container was removed") + } + if metricsExists { + t.Fatal("owned container was not removed") + } + want := [][]string{ + {"container", "inspect", "--format", "{{json .}}", metricsID}, + {"container", "rm", "--force", metricsID}, + {"container", "inspect", "--format", "{{json .}}", apiID}, + {"container", "inspect", "--format", "{{json .}}", apiID}, + } + if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { + t.Fatalf("protected cleanup calls:\n got: %q\nwant: %q", got, want) + } + + commandRunner.corruptLabel(apiID, sessionLabel, environment.sessionID) + if err := environment.Close(); err != nil { + t.Fatalf("Close() after ownership repair error = %v", err) + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after ownership repair = %d", commandRunner.containerCount()) + } +} + +func TestConcurrentSessionsCleanOnlyTheirOwnContainers(t *testing.T) { + commandRunner := newFakeDockerRunner() + sessions := []string{strings.Repeat("a", 24), strings.Repeat("d", 24)} + index := 0 + factory := newFactory(game.SandboxFactory{}, commandRunner, nil, func() (string, error) { + value := sessions[index] + index++ + return value, nil + }) + firstCreated, err := factory.Create(context.Background(), testDockerMission()) + if err != nil { + t.Fatal(err) + } + secondCreated, err := factory.Create(context.Background(), testDockerMission()) + if err != nil { + t.Fatal(err) + } + first := firstCreated.(*environment) + second := secondCreated.(*environment) + if err := first.Close(); err != nil { + t.Fatal(err) + } + if commandRunner.containerCount() != 2 { + t.Fatalf("first cleanup affected second session; remaining = %d", commandRunner.containerCount()) + } + for _, tracked := range second.snapshotContainers() { + commandRunner.mutex.Lock() + container := commandRunner.containers[tracked.id] + commandRunner.mutex.Unlock() + if container == nil || container.labels[sessionLabel] != sessions[1] { + t.Fatalf("second-session fixture %s was changed", tracked.alias) + } + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after both closes = %d", commandRunner.containerCount()) + } +} diff --git a/internal/dockerlab/commands_test.go b/internal/dockerlab/commands_test.go new file mode 100644 index 0000000..8ae78d7 --- /dev/null +++ b/internal/dockerlab/commands_test.go @@ -0,0 +1,151 @@ +package dockerlab + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func TestPlayerInputIsParsedBeforeDockerTransport(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + commandRunner.resetCalls() + api := environment.byAlias["api"] + + unsafe := []string{ + "docker start api; touch /tmp/escaped", + "docker start $(whoami)", + "docker start `whoami`", + "docker run --privileged busybox", + "docker start host-container", + "docker start " + api.id, + "docker start " + api.actualName, + "sh -c docker ps", + "docker ps | docker start api", + "docker logs api --tail 20", + "docker stop api metrics", + } + for _, line := range unsafe { + if _, err := environment.Execute(context.Background(), line); err == nil { + t.Errorf("Execute(%q) unexpectedly succeeded", line) + } + } + if calls := commandRunner.snapshotCalls(); len(calls) != 0 { + t.Fatalf("rejected player input reached Docker transport: %q", calls) + } + + if _, err := environment.Execute(context.Background(), "docker start api"); err != nil { + t.Fatalf("safe start error = %v", err) + } + if calls := commandRunner.snapshotCalls(); !reflect.DeepEqual(calls, [][]string{{"container", "start", api.id}}) { + t.Fatalf("safe start transport calls = %q", calls) + } +} + +func TestLogicalCommandsAndOutcomeObservation(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + + apiRunning := mission.Condition{Type: "docker_container_running", Container: "api"} + apiStopped := mission.Condition{Type: "docker_container_stopped", Container: "api"} + metricsRunning := mission.Condition{Type: "docker_container_running", Container: "metrics"} + count := 2 + containerCount := mission.Condition{Type: "docker_container_count_equals", Count: &count} + if got, err := environment.Observe(context.Background(), apiRunning); err != nil || got { + t.Fatalf("initial api outcome = %v, %v", got, err) + } + if got, err := environment.Observe(context.Background(), metricsRunning); err != nil || !got { + t.Fatalf("initial metrics outcome = %v, %v", got, err) + } + if got, err := environment.Observe(context.Background(), containerCount); err != nil || !got { + t.Fatalf("container count outcome = %v, %v", got, err) + } + extraID := strings.Repeat("e", 64) + commandRunner.mutex.Lock() + commandRunner.containers[extraID] = &fakeContainer{ + labels: map[string]string{ + managedLabel: "true", schemaLabel: "1", sessionLabel: environment.sessionID, missionLabel: environment.missionID, + }, + name: "opsquest-extra", + } + commandRunner.containerOrder = append(commandRunner.containerOrder, extraID) + commandRunner.mutex.Unlock() + if got, err := environment.Observe(context.Background(), containerCount); err != nil || got { + t.Fatalf("container count with extra attempt-owned fixture = %v, %v", got, err) + } + commandRunner.mutex.Lock() + delete(commandRunner.containers, extraID) + commandRunner.mutex.Unlock() + + listed, err := environment.Execute(context.Background(), "docker ps") + if err != nil { + t.Fatal(err) + } + if strings.Contains(listed.Output, "api") || !strings.Contains(listed.Output, "metrics") { + t.Fatalf("running list output:\n%s", listed.Output) + } + listed, err = environment.Execute(context.Background(), "docker container ls --all") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(listed.Output, "api") || !strings.Contains(listed.Output, "metrics") { + t.Fatalf("all list output:\n%s", listed.Output) + } + if strings.Contains(listed.Output, environment.byAlias["api"].id) || strings.Contains(listed.Output, environment.byAlias["api"].actualName) || strings.Contains(listed.Output, testImageReference) { + t.Fatalf("logical list leaked engine identifiers:\n%s", listed.Output) + } + + if _, err := environment.Execute(context.Background(), "docker restart api"); err != nil { + t.Fatal(err) + } + if got, err := environment.Observe(context.Background(), apiRunning); err != nil || !got { + t.Fatalf("api outcome after restart = %v, %v", got, err) + } + inspected, err := environment.Execute(context.Background(), "docker inspect api") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(inspected.Output, `"Name": "api"`) || !strings.Contains(inspected.Output, `"Running": true`) { + t.Fatalf("logical inspect output:\n%s", inspected.Output) + } + if strings.Contains(inspected.Output, environment.byAlias["api"].id) || strings.Contains(inspected.Output, environment.byAlias["api"].actualName) { + t.Fatalf("logical inspect leaked engine identifiers:\n%s", inspected.Output) + } + if _, err := environment.Execute(context.Background(), "docker container stop api"); err != nil { + t.Fatal(err) + } + if got, err := environment.Observe(context.Background(), apiStopped); err != nil || !got { + t.Fatalf("api stopped outcome after stop = %v, %v", got, err) + } +} + +func TestHelpAndCompletionExposeOnlyTeachingSubset(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + commandRunner.resetCalls() + + result, err := environment.Execute(context.Background(), "docker --help") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"docker ps", "docker start ALIAS", "docker stop ALIAS", "docker inspect ALIAS", "docker logs ALIAS"} { + if !strings.Contains(result.Output, expected) { + t.Errorf("help missing %q:\n%s", expected, result.Output) + } + } + if calls := commandRunner.snapshotCalls(); len(calls) != 0 { + t.Fatalf("help reached Docker transport: %q", calls) + } + + completion := environment.CompletionSource() + if got := completion.CommandNames(); !reflect.DeepEqual(got, []string{"docker", "help"}) { + t.Fatalf("command completion = %q", got) + } + if got := completion.PathCandidates("a"); !reflect.DeepEqual(got, []game.CompletionCandidate{{Value: "api"}}) { + t.Fatalf("alias completion = %#v", got) + } +} diff --git a/internal/dockerlab/completion.go b/internal/dockerlab/completion.go new file mode 100644 index 0000000..2f358aa --- /dev/null +++ b/internal/dockerlab/completion.go @@ -0,0 +1,42 @@ +package dockerlab + +import ( + "sort" + "strings" + + "github.com/aleksandergregersen/opsquest/internal/game" +) + +func (e *environment) CompletionSource() game.CompletionSource { + return dockerCompletion{environment: e} +} + +type dockerCompletion struct { + environment *environment +} + +func (dockerCompletion) CommandNames() []string { + return []string{"docker", "help"} +} + +func (c dockerCompletion) PathCandidates(prefix string) []game.CompletionCandidate { + values := []string{"--all", "--filter", "--tail", "-a", "connect", "container", "create", "disconnect", "inspect", "logs", "ls", "network", "ps", "restart", "rm", "start", "stop"} + for _, tracked := range c.environment.snapshotContainers() { + values = append(values, tracked.alias) + } + seen := make(map[string]bool) + for _, network := range c.environment.snapshotNetworks() { + if !seen[network.name] { + seen[network.name] = true + values = append(values, network.name) + } + } + sort.Strings(values) + candidates := make([]game.CompletionCandidate, 0) + for _, value := range values { + if strings.HasPrefix(value, prefix) { + candidates = append(candidates, game.CompletionCandidate{Value: value}) + } + } + return candidates +} diff --git a/internal/dockerlab/container_commands.go b/internal/dockerlab/container_commands.go new file mode 100644 index 0000000..d516295 --- /dev/null +++ b/internal/dockerlab/container_commands.go @@ -0,0 +1,70 @@ +package dockerlab + +import ( + "context" + "fmt" + "strconv" +) + +func (e *environment) startContainer(ctx context.Context, alias string) error { + _, err := e.runOnAlias(ctx, alias, "container", "start") + return err +} + +func (e *environment) restartContainer(ctx context.Context, alias string) error { + _, err := e.runOnAlias(ctx, alias, "container", "restart") + return err +} + +func (e *environment) stopContainer(ctx context.Context, alias string) error { + _, err := e.runOnAlias(ctx, alias, "container", "stop") + return err +} + +// removeContainer deletes one stopped mission container. Running containers +// are refused rather than forced so the lesson keeps stop and remove distinct. +func (e *environment) removeContainer(ctx context.Context, alias string) error { + tracked, exists := e.container(alias) + if !exists { + return fmt.Errorf("docker: container %q is not part of this mission", alias) + } + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("docker: container %q has already been removed", alias) + } + if inspection.State.Running { + return fmt.Errorf("docker: cannot remove running container %q; stop it first", alias) + } + _, err = e.runOnAlias(ctx, alias, "container", "rm") + return err +} + +// runOnAlias runs one fixed Docker action against the exact container ID +// behind alias. Engine errors about a missing container are translated so +// removed fixtures never reveal their real container ID. +func (e *environment) runOnAlias(ctx context.Context, alias string, args ...string) (runResult, error) { + tracked, exists := e.container(alias) + if !exists { + return runResult{}, fmt.Errorf("docker: container %q is not part of this mission", alias) + } + result, err := e.runRedacted(ctx, []identity{tracked.identity()}, append(args, tracked.id)...) + if err != nil && isMissingContainer(result) { + return result, fmt.Errorf("docker: container %q has been removed", alias) + } + return result, err +} + +func (e *environment) containerLogs(ctx context.Context, alias string, tail int) (string, error) { + args := []string{"container", "logs"} + if tail >= 0 { + args = append(args, "--tail", strconv.Itoa(tail)) + } + result, err := e.runOnAlias(ctx, alias, args...) + if err != nil { + return "", err + } + return result.stdout, nil +} diff --git a/internal/dockerlab/container_list.go b/internal/dockerlab/container_list.go new file mode 100644 index 0000000..3b6e9f5 --- /dev/null +++ b/internal/dockerlab/container_list.go @@ -0,0 +1,102 @@ +package dockerlab + +import ( + "context" + "fmt" + "strings" +) + +func (e *environment) listContainers(ctx context.Context, all bool, filters []listFilter) (string, error) { + type row struct { + logicalID string + image string + status string + alias string + } + rows := make([]row, 0, len(e.containers)) + // Eight columns preserves the original listing layout for plain states. + statusWidth := 8 + for _, tracked := range e.snapshotContainers() { + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil { + return "", err + } + // A status filter selects stopped containers even without --all, + // matching docker ps. + if !exists || !all && !hasFilterKey(filters, "status") && !inspection.State.Running || !matchesFilters(inspection, filters) { + continue + } + status := displayStatus(inspection) + statusWidth = max(statusWidth, len(status)) + rows = append(rows, row{logicalID: tracked.logicalID, image: tracked.imageAlias, status: status, alias: tracked.alias}) + } + var output strings.Builder + fmt.Fprintf(&output, "%-13s %-9s %-*s %s\n", "CONTAINER ID", "IMAGE", statusWidth, "STATUS", "NAMES") + for _, item := range rows { + fmt.Fprintf(&output, "%-13s %-9s %-*s %s\n", item.logicalID, item.image, statusWidth, item.status, item.alias) + } + return output.String(), nil +} + +func displayStatus(inspection containerInspection) string { + status := inspection.State.Status + if status == "" { + if inspection.State.Running { + status = "running" + } else { + status = "stopped" + } + } + if health := healthStatus(inspection); inspection.State.Running && health != "none" { + status += " (" + health + ")" + } + return status +} + +// healthStatus reports the probe verdict, or none for a fixture without a +// health probe. Like docker ps, listings only show it for running containers. +func healthStatus(inspection containerInspection) string { + if inspection.State.Health == nil || inspection.State.Health.Status == "" { + return "none" + } + return inspection.State.Health.Status +} + +func hasFilterKey(filters []listFilter, key string) bool { + for _, filter := range filters { + if filter.key == key { + return true + } + } + return false +} + +// matchesFilters follows docker ps semantics: values for one key are +// alternatives, and different keys must all match. +func matchesFilters(inspection containerInspection, filters []listFilter) bool { + matched := make(map[string]bool, len(filters)) + for _, filter := range filters { + if _, seen := matched[filter.key]; !seen { + matched[filter.key] = false + } + var actual string + switch filter.key { + case "status": + actual = inspection.State.Status + case "health": + actual = "none" + if inspection.State.Running { + actual = healthStatus(inspection) + } + } + if actual == filter.value { + matched[filter.key] = true + } + } + for _, ok := range matched { + if !ok { + return false + } + } + return true +} diff --git a/internal/dockerlab/dockerlab_test.go b/internal/dockerlab/dockerlab_test.go deleted file mode 100644 index 2efa9bb..0000000 --- a/internal/dockerlab/dockerlab_test.go +++ /dev/null @@ -1,1311 +0,0 @@ -package dockerlab - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "reflect" - "strconv" - "strings" - "sync" - "testing" - "time" - - "github.com/aleksandergregersen/opsquest/internal/game" - "github.com/aleksandergregersen/opsquest/internal/mission" -) - -const ( - testImageReference = "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" - testOwnerPID = 4242 - testOwnerHost = "test-host" -) - -var testNow = time.Date(2026, time.September, 30, 12, 0, 0, 0, time.UTC) - -type fakeContainer struct { - labels map[string]string - name string - created time.Time - running bool - restarting bool - status string - log string - exitCode int - oneShot bool - restartPolicy string - restartCount int - healthCmd string - // endpoints maps an attached network's engine name to its ID. - endpoints map[string]string -} - -type fakeNetwork struct { - labels map[string]string - name string - created time.Time - internal bool -} - -type fakeDockerRunner struct { - mutex sync.Mutex - - calls [][]string - containers map[string]*fakeContainer - containerOrder []string - contextName string - contextErr error - daemonErr error - imageAvailable bool - failCreateAt int - failAfterCreateAt int - createAttempts int - failNextStart bool - failNextInspect map[string]error - hideNextInspect map[string]bool - failNextRemove map[string]error - failManagedList bool - stripHealth bool - networks map[string]*fakeNetwork - networkOrder []string - networkCreates int - failNetworkCreate bool - // contextFailure makes every invocation fail the way the Docker CLI does - // when the selected context cannot be resolved. - contextFailure bool - // contextFailureOnNetworkRemove fails only network removal that way. - contextFailureOnNetworkRemove bool -} - -// contextNotFoundMessage is the Docker CLI's output when the selected -// context is missing, captured from Docker 29.7.2. -const contextNotFoundMessage = `Failed to initialize: unable to resolve docker endpoint: context "opsquest-missing-context": context not found: open /home/player/.docker/contexts/meta/fdff/meta.json: no such file or directory` - -func (r *fakeDockerRunner) setContextFailure(failing bool) { - r.mutex.Lock() - defer r.mutex.Unlock() - r.contextFailure = failing -} - -func newFakeDockerRunner() *fakeDockerRunner { - return &fakeDockerRunner{ - containers: make(map[string]*fakeContainer), - networks: make(map[string]*fakeNetwork), - contextName: "default", - imageAvailable: true, - failNextInspect: make(map[string]error), - hideNextInspect: make(map[string]bool), - failNextRemove: make(map[string]error), - } -} - -func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, error) { - r.mutex.Lock() - defer r.mutex.Unlock() - if err := ctx.Err(); err != nil { - return runResult{}, err - } - r.calls = append(r.calls, append([]string(nil), args...)) - if r.contextFailure { - return runResult{stderr: contextNotFoundMessage}, errors.New("exit status 1") - } - - switch { - case hasPrefix(args, "context", "show"): - if r.contextErr != nil { - return runResult{stderr: r.contextErr.Error()}, r.contextErr - } - return runResult{stdout: r.contextName + "\n"}, nil - case hasPrefix(args, "version", "--format"): - if r.daemonErr != nil { - return runResult{stderr: r.daemonErr.Error()}, r.daemonErr - } - return runResult{stdout: "27.0.0\n"}, nil - case hasPrefix(args, "image", "inspect", "--format"): - if !r.imageAvailable { - return runResult{stderr: "Error: No such image"}, errors.New("exit status 1") - } - return runResult{stdout: "sha256:test-image\n"}, nil - case hasPrefix(args, "container", "create"): - r.createAttempts++ - if r.failCreateAt == r.createAttempts { - return runResult{stderr: "fixture creation failed"}, errors.New("exit status 1") - } - // Created IDs use a prefix no seeded test container shares. - id := fmt.Sprintf("c0ffee%058x", r.createAttempts) - labels := make(map[string]string) - name := "" - restartPolicy := "" - healthCmd := "" - endpoints := make(map[string]string) - for index := 0; index+1 < len(args); index++ { - switch args[index] { - case "--network": - if args[index+1] == "none" { - endpoints["none"] = "none-network-id" - } else if network, exists := r.networks[args[index+1]]; exists { - endpoints[network.name] = args[index+1] - } else { - return runResult{stderr: "Error response from daemon: network " + args[index+1] + " not found"}, errors.New("exit status 1") - } - case "--name": - name = args[index+1] - case "--label": - labelName, value, found := strings.Cut(args[index+1], "=") - if found { - labels[labelName] = value - } - case "--restart": - restartPolicy = args[index+1] - case "--health-cmd": - healthCmd = args[index+1] - } - } - container := &fakeContainer{labels: labels, name: name, created: testNow, status: "created", restartPolicy: restartPolicy, healthCmd: healthCmd, endpoints: endpoints} - if len(args) >= 3 && args[len(args)-3] == "opsquest-service" { - container.log = args[len(args)-2] + "\n" - } - if len(args) >= 3 && args[len(args)-3] == "opsquest-diagnostic" { - container.oneShot = true - container.log = args[len(args)-2] + "\n" - exitCode, err := strconv.Atoi(args[len(args)-1]) - if err != nil { - return runResult{}, err - } - container.exitCode = exitCode - } - r.containers[id] = container - r.containerOrder = append(r.containerOrder, id) - if r.failAfterCreateAt == r.createAttempts { - return runResult{stderr: "connection lost after create"}, errors.New("exit status 1") - } - return runResult{stdout: id + "\n"}, nil - case hasPrefix(args, "container", "start") || hasPrefix(args, "container", "restart"): - if r.failNextStart { - r.failNextStart = false - return runResult{stderr: "start failed"}, errors.New("exit status 1") - } - id := args[len(args)-1] - container, exists := r.containers[id] - if !exists { - return missingContainerResult(id) - } - if container.oneShot && strings.HasPrefix(container.restartPolicy, "on-failure") { - // Model a crash loop observed between restarts: Docker reports a - // restarting container as running and appends each attempt's log. - container.running = true - container.restarting = true - container.status = "restarting" - container.restartCount += crashLoopReadyRestarts - container.log = strings.Repeat(strings.SplitAfter(container.log, "\n")[0], container.restartCount+1) - } else if container.oneShot { - container.running = false - container.status = "exited" - } else { - container.running = true - container.status = "running" - container.exitCode = 0 - } - return runResult{stdout: id + "\n"}, nil - case hasPrefix(args, "container", "stop"): - id := args[len(args)-1] - container, exists := r.containers[id] - if !exists { - return missingContainerResult(id) - } - container.running = false - container.restarting = false - container.status = "exited" - return runResult{stdout: id + "\n"}, nil - case hasPrefix(args, "container", "wait"): - id := args[len(args)-1] - container, exists := r.containers[id] - if !exists { - return missingContainerResult(id) - } - return runResult{stdout: strconv.Itoa(container.exitCode) + "\n"}, nil - case hasPrefix(args, "container", "logs"): - id := args[len(args)-1] - container, exists := r.containers[id] - if !exists { - return missingContainerResult(id) - } - if len(args) == 5 && args[2] == "--tail" { - tail, err := strconv.Atoi(args[3]) - if err != nil { - return runResult{}, err - } - lines := strings.SplitAfter(container.log, "\n") - if lines[len(lines)-1] == "" { - lines = lines[:len(lines)-1] - } - if tail < len(lines) { - lines = lines[len(lines)-tail:] - } - return runResult{stdout: strings.Join(lines, "")}, nil - } - return runResult{stdout: container.log}, nil - case hasPrefix(args, "container", "inspect", "--format"): - reference := args[len(args)-1] - if err := r.failNextInspect[reference]; err != nil { - delete(r.failNextInspect, reference) - return runResult{stderr: err.Error()}, err - } - if r.hideNextInspect[reference] { - delete(r.hideNextInspect, reference) - return missingContainerResult(reference) - } - id := r.resolveContainer(reference) - container, exists := r.containers[id] - if !exists { - return missingContainerResult(id) - } - inspection := containerInspection{ID: id, Name: "/" + container.name, Created: container.created.Format(time.RFC3339Nano), RestartCount: container.restartCount} - inspection.Config.Labels = cloneStrings(container.labels) - inspection.State.Running = container.running - inspection.State.Restarting = container.restarting - inspection.State.Status = container.status - inspection.State.ExitCode = container.exitCode - if policy, retries, found := strings.Cut(container.restartPolicy, ":"); found { - inspection.HostConfig.RestartPolicy.Name = policy - inspection.HostConfig.RestartPolicy.MaximumRetryCount, _ = strconv.Atoi(retries) - } else { - inspection.HostConfig.RestartPolicy.Name = container.restartPolicy - } - if len(container.endpoints) > 0 { - inspection.NetworkSettings.Networks = make(map[string]endpointState) - for name, networkID := range container.endpoints { - // Docker can leave NetworkID empty before a first start. - if container.status == "created" { - networkID = "" - } - inspection.NetworkSettings.Networks[name] = endpointState{NetworkID: networkID} - } - } - if container.healthCmd != "" && container.running && !r.stripHealth { - health := "unhealthy" - if container.healthCmd == "true" { - health = "healthy" - } - inspection.State.Health = &healthState{Status: health} - } - encoded, err := json.Marshal(inspection) - if err != nil { - return runResult{}, err - } - return runResult{stdout: string(encoded) + "\n"}, nil - case hasPrefix(args, "container", "ls", "--all", "--quiet", "--no-trunc") && r.failManagedList && args[len(args)-1] == "label="+managedLabel+"=true": - return runResult{stderr: "list failed"}, errors.New("exit status 1") - case hasPrefix(args, "container", "ls", "--all", "--quiet", "--no-trunc"): - filters := make(map[string]string) - for index := 0; index+1 < len(args); index++ { - if args[index] != "--filter" { - continue - } - filter := strings.TrimPrefix(args[index+1], "label=") - name, value, found := strings.Cut(filter, "=") - if found { - filters[name] = value - } - } - var output strings.Builder - for _, id := range r.containerOrder { - container, exists := r.containers[id] - if !exists || !labelsMatch(container.labels, filters) { - continue - } - output.WriteString(id) - output.WriteByte('\n') - } - return runResult{stdout: output.String()}, nil - case hasPrefix(args, "network", "create"): - r.networkCreates++ - if r.failNetworkCreate { - return runResult{stderr: "network creation failed"}, errors.New("exit status 1") - } - network := &fakeNetwork{labels: make(map[string]string), name: args[len(args)-1], created: testNow} - for index := 2; index < len(args)-1; index++ { - switch args[index] { - case "--internal": - network.internal = true - case "--label": - labelName, value, _ := strings.Cut(args[index+1], "=") - network.labels[labelName] = value - index++ - case "--driver": - index++ - } - } - id := fmt.Sprintf("feed%060x", r.networkCreates) - r.networks[id] = network - r.networkOrder = append(r.networkOrder, id) - return runResult{stdout: id + "\n"}, nil - case hasPrefix(args, "network", "inspect", "--format"): - reference := args[len(args)-1] - id := r.resolveNetwork(reference) - network, exists := r.networks[id] - if !exists { - return runResult{stderr: "Error response from daemon: network " + reference + " not found"}, errors.New("exit status 1") - } - encoded, err := json.Marshal(networkInspection{ID: id, Name: network.name, Created: network.created.Format(time.RFC3339Nano), Driver: "bridge", Internal: network.internal, Labels: cloneStrings(network.labels)}) - if err != nil { - return runResult{}, err - } - return runResult{stdout: string(encoded) + "\n"}, nil - case hasPrefix(args, "network", "ls", "--quiet", "--no-trunc"): - filter := strings.TrimPrefix(args[len(args)-1], "label=") - name, value, _ := strings.Cut(filter, "=") - var output strings.Builder - for _, id := range r.networkOrder { - if network, exists := r.networks[id]; exists && network.labels[name] == value { - output.WriteString(id + "\n") - } - } - return runResult{stdout: output.String()}, nil - case hasPrefix(args, "network", "rm") && r.contextFailureOnNetworkRemove: - return runResult{stderr: contextNotFoundMessage}, errors.New("exit status 1") - case hasPrefix(args, "network", "rm"): - id := args[len(args)-1] - network, exists := r.networks[id] - if !exists { - return runResult{stderr: "Error response from daemon: network " + id + " not found"}, errors.New("exit status 1") - } - for _, container := range r.containers { - if _, attached := container.endpoints[network.name]; attached && container.running { - return runResult{stderr: "Error response from daemon: error while removing network: network " + network.name + " id " + id + " has active endpoints"}, errors.New("exit status 1") - } - } - delete(r.networks, id) - return runResult{stdout: id + "\n"}, nil - case hasPrefix(args, "network", "connect") || hasPrefix(args, "network", "disconnect"): - networkID, containerID := args[len(args)-2], args[len(args)-1] - network, exists := r.networks[networkID] - if !exists { - return runResult{stderr: "Error response from daemon: network " + networkID + " not found"}, errors.New("exit status 1") - } - container, exists := r.containers[containerID] - if !exists { - return missingContainerResult(containerID) - } - _, attached := container.endpoints[network.name] - if args[1] == "disconnect" { - if !attached { - return runResult{stderr: "Error response from daemon: container " + containerID + " is not connected to network " + network.name}, errors.New("exit status 1") - } - delete(container.endpoints, network.name) - return runResult{}, nil - } - if _, disabled := container.endpoints["none"]; disabled { - return runResult{stderr: "Error response from daemon: container cannot be connected to multiple networks with one of the networks in private (none) mode"}, errors.New("exit status 1") - } - if attached { - return runResult{stderr: "Error response from daemon: endpoint with name " + container.name + " already exists in network " + network.name}, errors.New("exit status 1") - } - if container.endpoints == nil { - container.endpoints = make(map[string]string) - } - container.endpoints[network.name] = networkID - return runResult{}, nil - case len(args) == 3 && hasPrefix(args, "container", "rm"): - id := args[len(args)-1] - container, exists := r.containers[id] - if !exists { - return missingContainerResult(id) - } - if container.running { - return runResult{stderr: "Error response from daemon: cannot remove container: container is running: stop the container before removing or force remove"}, errors.New("exit status 1") - } - delete(r.containers, id) - return runResult{stdout: id + "\n"}, nil - case hasPrefix(args, "container", "rm", "--force"): - id := args[len(args)-1] - if err := r.failNextRemove[id]; err != nil { - delete(r.failNextRemove, id) - return runResult{stderr: err.Error()}, err - } - if _, exists := r.containers[id]; !exists { - return missingContainerResult(id) - } - delete(r.containers, id) - return runResult{stdout: id + "\n"}, nil - default: - return runResult{stderr: "unexpected fake Docker invocation"}, fmt.Errorf("unexpected Docker args: %q", args) - } -} - -func (r *fakeDockerRunner) resolveContainer(reference string) string { - if _, exists := r.containers[reference]; exists { - return reference - } - for id, container := range r.containers { - if container.name == reference { - return id - } - } - return reference -} - -func (r *fakeDockerRunner) resolveNetwork(reference string) string { - if _, exists := r.networks[reference]; exists { - return reference - } - for id, network := range r.networks { - if network.name == reference { - return id - } - } - return reference -} - -func (r *fakeDockerRunner) seedNetwork(id string, network *fakeNetwork) { - r.mutex.Lock() - defer r.mutex.Unlock() - r.networks[id] = network - r.networkOrder = append(r.networkOrder, id) -} - -func (r *fakeDockerRunner) hasNetwork(id string) bool { - r.mutex.Lock() - defer r.mutex.Unlock() - _, exists := r.networks[id] - return exists -} - -func (r *fakeDockerRunner) networkCount() int { - r.mutex.Lock() - defer r.mutex.Unlock() - return len(r.networks) -} - -func labelsMatch(labels, filters map[string]string) bool { - for name, value := range filters { - if labels[name] != value { - return false - } - } - return true -} - -func (r *fakeDockerRunner) seedContainer(id string, container *fakeContainer) { - r.mutex.Lock() - defer r.mutex.Unlock() - r.containers[id] = container - r.containerOrder = append(r.containerOrder, id) -} - -func (r *fakeDockerRunner) hasContainer(id string) bool { - r.mutex.Lock() - defer r.mutex.Unlock() - _, exists := r.containers[id] - return exists -} - -func (r *fakeDockerRunner) resetCalls() { - r.mutex.Lock() - defer r.mutex.Unlock() - r.calls = nil -} - -func (r *fakeDockerRunner) snapshotCalls() [][]string { - r.mutex.Lock() - defer r.mutex.Unlock() - result := make([][]string, len(r.calls)) - for index := range r.calls { - result[index] = append([]string(nil), r.calls[index]...) - } - return result -} - -func (r *fakeDockerRunner) containerCount() int { - r.mutex.Lock() - defer r.mutex.Unlock() - return len(r.containers) -} - -func (r *fakeDockerRunner) corruptLabel(id, name, value string) { - r.mutex.Lock() - defer r.mutex.Unlock() - r.containers[id].labels[name] = value -} - -func (r *fakeDockerRunner) failRemoveOnce(id string, err error) { - r.mutex.Lock() - defer r.mutex.Unlock() - r.failNextRemove[id] = err -} - -func (r *fakeDockerRunner) removeContainer(id string) { - r.mutex.Lock() - defer r.mutex.Unlock() - delete(r.containers, id) -} - -func missingContainerResult(id string) (runResult, error) { - return runResult{stderr: "Error: No such container: " + id}, errors.New("exit status 1") -} - -func hasPrefix(actual []string, prefix ...string) bool { - return len(actual) >= len(prefix) && reflect.DeepEqual(actual[:len(prefix)], prefix) -} - -func cloneStrings(source map[string]string) map[string]string { - clone := make(map[string]string, len(source)) - for key, value := range source { - clone[key] = value - } - return clone -} - -func testDockerMission() mission.Mission { - return mission.Mission{ - ID: "docker-container-census", - Number: 17, - Track: mission.TrackDocker, - Environment: mission.EnvironmentDocker, - Docker: &mission.DockerSetup{ - Images: []mission.DockerImageSpec{{Alias: "fixture", Reference: testImageReference}}, - Containers: []mission.DockerContainerSpec{ - {Name: "api", Image: "fixture", State: "stopped"}, - {Name: "metrics", Image: "fixture", State: "running"}, - }, - }, - } -} - -func testDiagnosticDockerMission() mission.Mission { - exitCode := 23 - return mission.Mission{ - ID: "docker-diagnostic", - Number: 21, - Track: mission.TrackDocker, - Environment: mission.EnvironmentDocker, - Docker: &mission.DockerSetup{ - Images: []mission.DockerImageSpec{{Alias: "fixture", Reference: testImageReference}}, - Containers: []mission.DockerContainerSpec{{ - Name: "job", - Image: "fixture", - State: mission.DockerStateStopped, - Log: "ERROR: literal $(touch /host); still data", - ExitCode: &exitCode, - }}, - }, - } -} - -func testFactory(commandRunner runner) *Factory { - factory := newFactory(game.SandboxFactory{}, commandRunner, nil, func() (string, error) { - return strings.Repeat("a", 24), nil - }) - factory.owner = testOwner(func(pid int) bool { return pid == testOwnerPID }) - factory.pollInterval = time.Millisecond - return factory -} - -func testOwner(alive func(int) bool) processOwner { - return processOwner{pid: testOwnerPID, host: testOwnerHost, alive: alive, now: func() time.Time { return testNow }} -} - -// firstCall returns the first recorded Docker invocation with prefix and its -// position, so setup assertions do not depend on unrelated preflight calls. -func firstCall(t *testing.T, calls [][]string, prefix ...string) (int, []string) { - t.Helper() - for index, call := range calls { - if hasPrefix(call, prefix...) { - return index, call - } - } - t.Fatalf("no Docker call with prefix %q in %q", prefix, calls) - return -1, nil -} - -func createTestEnvironment(t *testing.T, commandRunner *fakeDockerRunner) *environment { - t.Helper() - created, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()) - if err != nil { - t.Fatalf("Create() error = %v", err) - } - environment, ok := created.(*environment) - if !ok { - t.Fatalf("Create() type = %T, want *environment", created) - } - t.Cleanup(func() { - _ = environment.Close() - }) - return environment -} - -func createEnvironmentForMission(t *testing.T, commandRunner *fakeDockerRunner, item mission.Mission) *environment { - t.Helper() - created, err := testFactory(commandRunner).Create(context.Background(), item) - if err != nil { - t.Fatalf("Create() error = %v", err) - } - environment, ok := created.(*environment) - if !ok { - t.Fatalf("Create() type = %T, want *environment", created) - } - t.Cleanup(func() { - _ = environment.Close() - }) - return environment -} - -func TestAvailabilityDistinguishesOptionalDockerFailures(t *testing.T) { - item := testDockerMission() - t.Run("CLI missing", func(t *testing.T) { - factory := newFactory(game.SandboxFactory{}, nil, execNotFoundError{}, nil) - availability := factory.Availability(context.Background(), item) - if availability.Available || !strings.Contains(availability.Detail, "executable not found") || !strings.Contains(availability.Detail, "OrbStack") || !strings.Contains(availability.Detail, "Linux missions remain available") { - t.Fatalf("availability = %#v", availability) - } - }) - - t.Run("daemon unavailable", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.daemonErr = errors.New("Cannot connect to the Docker daemon") - availability := testFactory(commandRunner).Availability(context.Background(), item) - if availability.Available || !strings.Contains(availability.Detail, "Docker-compatible engine could not be reached") || !strings.Contains(availability.Detail, "start Docker or OrbStack") { - t.Fatalf("availability = %#v", availability) - } - }) - - t.Run("OrbStack unavailable", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.contextName = "orbstack" - commandRunner.daemonErr = errors.New("Cannot connect to the Docker daemon") - availability := testFactory(commandRunner).Availability(context.Background(), item) - if availability.Available || !strings.Contains(availability.Detail, "start OrbStack") || !strings.Contains(availability.Detail, "'orbstack' Docker context") { - t.Fatalf("availability = %#v", availability) - } - }) - - t.Run("image missing", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.imageAvailable = false - availability := testFactory(commandRunner).Availability(context.Background(), item) - if availability.Available || !strings.Contains(availability.Detail, "docker pull "+testImageReference) || !strings.Contains(availability.Detail, "never pulls") { - t.Fatalf("availability = %#v", availability) - } - for _, call := range commandRunner.snapshotCalls() { - if len(call) > 0 && call[0] == "pull" { - t.Fatalf("Availability invoked an image pull: %q", call) - } - } - }) - - t.Run("OrbStack image missing", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.contextName = "orbstack" - commandRunner.imageAvailable = false - availability := testFactory(commandRunner).Availability(context.Background(), item) - if availability.Available || !strings.Contains(availability.Detail, "DOCKER_CONTEXT=orbstack docker pull "+testImageReference) { - t.Fatalf("availability = %#v", availability) - } - }) - - t.Run("ready", func(t *testing.T) { - availability := testFactory(newFakeDockerRunner()).Availability(context.Background(), item) - if !availability.Available || availability.Detail != "Docker is ready for this mission." { - t.Fatalf("availability = %#v", availability) - } - }) - - t.Run("OrbStack ready", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.contextName = "orbstack" - availability := testFactory(commandRunner).Availability(context.Background(), item) - if !availability.Available || !strings.Contains(availability.Detail, "OrbStack is ready") || !strings.Contains(availability.Detail, "'orbstack' Docker context") { - t.Fatalf("availability = %#v", availability) - } - }) - - t.Run("context detection failure is advisory", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.contextErr = errors.New("context metadata unavailable") - availability := testFactory(commandRunner).Availability(context.Background(), item) - if !availability.Available || availability.Detail != "Docker is ready for this mission." { - t.Fatalf("availability = %#v", availability) - } - }) -} - -type execNotFoundError struct{} - -func (execNotFoundError) Error() string { return "executable not found" } - -func TestCreateUsesExactLabelsAndResourceLimits(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createTestEnvironment(t, commandRunner) - calls := commandRunner.snapshotCalls() - if len(calls) < 6 { - t.Fatalf("setup calls = %q", calls) - } - wantCreate := []string{ - "container", "create", - "--pull", "never", - "--name", "opsquest-aaaaaaaaaaaaaaaaaaaaaaaa-c01", - "--label", "com.opsquest.managed=true", - "--label", "com.opsquest.schema=1", - "--label", "com.opsquest.session=aaaaaaaaaaaaaaaaaaaaaaaa", - "--label", "com.opsquest.mission=docker-container-census", - "--label", "com.opsquest.alias=api", - "--label", "com.opsquest.owner-pid=4242", - "--label", "com.opsquest.owner-host=test-host", - "--network", "none", - "--read-only", - "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=8m", - "--user", "65532:65532", - "--cap-drop", "ALL", - "--security-opt", "no-new-privileges", - "--pids-limit", "64", - "--memory", "128m", - "--memory-swap", "128m", - "--cpus", "0.5", - "--ulimit", "nofile=256:256", - "--restart", "no", - "--stop-timeout", "1", - "--entrypoint", "/bin/sleep", - testImageReference, - "86400", - } - createIndex, create := firstCall(t, calls, "container", "create") - if !reflect.DeepEqual(create, wantCreate) { - t.Fatalf("first create args:\n got: %q\nwant: %q", create, wantCreate) - } - metricsID := environment.byAlias["metrics"].id - if !reflect.DeepEqual(calls[createIndex+2], []string{"container", "start", metricsID}) { - t.Fatalf("running fixture start = %q", calls[createIndex+2]) - } - for _, call := range calls { - joined := strings.Join(call, " ") - for _, forbidden := range []string{"--privileged", "--volume", "--mount", "/var/run/docker.sock", "--network host"} { - if strings.Contains(joined, forbidden) { - t.Errorf("unsafe argument %q in %q", forbidden, call) - } - } - } -} - -func TestDiagnosticFixtureUsesFixedCommandAndExposesSanitizedObservations(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createEnvironmentForMission(t, commandRunner, testDiagnosticDockerMission()) - calls := commandRunner.snapshotCalls() - if len(calls) < 6 { - t.Fatalf("setup calls = %q", calls) - } - logText := "ERROR: literal $(touch /host); still data" - wantTail := []string{ - "--entrypoint", "/bin/sh", - testImageReference, - "-c", `printf '%s\n' "$1"; exit "$2"`, - "opsquest-diagnostic", logText, "23", - } - createIndex, create := firstCall(t, calls, "container", "create") - if len(create) < len(wantTail) || !reflect.DeepEqual(create[len(create)-len(wantTail):], wantTail) { - t.Fatalf("diagnostic create tail:\n got: %q\nwant: %q", create, wantTail) - } - if strings.Contains(create[len(create)-4], logText) { - t.Fatalf("diagnostic data was interpolated into shell program: %q", create[len(create)-4]) - } - jobID := environment.byAlias["job"].id - if !reflect.DeepEqual(calls[createIndex+1], []string{"container", "start", jobID}) || !reflect.DeepEqual(calls[createIndex+2], []string{"container", "wait", jobID}) { - t.Fatalf("diagnostic lifecycle calls = %q", calls[createIndex+1:createIndex+3]) - } - - logged, err := environment.Execute(context.Background(), "docker container logs job") - if err != nil || logged.Output != logText+"\n" { - t.Fatalf("logs output = %q, error = %v", logged.Output, err) - } - inspected, err := environment.Execute(context.Background(), "docker container inspect job") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(inspected.Output, `"Name": "job"`) || !strings.Contains(inspected.Output, `"ExitCode": 23`) || strings.Contains(inspected.Output, jobID) || strings.Contains(inspected.Output, environment.byAlias["job"].actualName) { - t.Fatalf("sanitized diagnostic inspection:\n%s", inspected.Output) - } - stopped := mission.Condition{Type: mission.ConditionDockerContainerStopped, Container: "job"} - if got, err := environment.Observe(context.Background(), stopped); err != nil || !got { - t.Fatalf("diagnostic stopped outcome = %v, %v", got, err) - } -} - -func TestPlayerInputIsParsedBeforeDockerTransport(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createTestEnvironment(t, commandRunner) - commandRunner.resetCalls() - api := environment.byAlias["api"] - - unsafe := []string{ - "docker start api; touch /tmp/escaped", - "docker start $(whoami)", - "docker start `whoami`", - "docker run --privileged busybox", - "docker start host-container", - "docker start " + api.id, - "docker start " + api.actualName, - "sh -c docker ps", - "docker ps | docker start api", - "docker logs api --tail 20", - "docker stop api metrics", - } - for _, line := range unsafe { - if _, err := environment.Execute(context.Background(), line); err == nil { - t.Errorf("Execute(%q) unexpectedly succeeded", line) - } - } - if calls := commandRunner.snapshotCalls(); len(calls) != 0 { - t.Fatalf("rejected player input reached Docker transport: %q", calls) - } - - if _, err := environment.Execute(context.Background(), "docker start api"); err != nil { - t.Fatalf("safe start error = %v", err) - } - if calls := commandRunner.snapshotCalls(); !reflect.DeepEqual(calls, [][]string{{"container", "start", api.id}}) { - t.Fatalf("safe start transport calls = %q", calls) - } -} - -func TestLogicalCommandsAndOutcomeObservation(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createTestEnvironment(t, commandRunner) - - apiRunning := mission.Condition{Type: "docker_container_running", Container: "api"} - apiStopped := mission.Condition{Type: "docker_container_stopped", Container: "api"} - metricsRunning := mission.Condition{Type: "docker_container_running", Container: "metrics"} - count := 2 - containerCount := mission.Condition{Type: "docker_container_count_equals", Count: &count} - if got, err := environment.Observe(context.Background(), apiRunning); err != nil || got { - t.Fatalf("initial api outcome = %v, %v", got, err) - } - if got, err := environment.Observe(context.Background(), metricsRunning); err != nil || !got { - t.Fatalf("initial metrics outcome = %v, %v", got, err) - } - if got, err := environment.Observe(context.Background(), containerCount); err != nil || !got { - t.Fatalf("container count outcome = %v, %v", got, err) - } - extraID := strings.Repeat("e", 64) - commandRunner.mutex.Lock() - commandRunner.containers[extraID] = &fakeContainer{ - labels: map[string]string{ - managedLabel: "true", schemaLabel: "1", sessionLabel: environment.sessionID, missionLabel: environment.missionID, - }, - name: "opsquest-extra", - } - commandRunner.containerOrder = append(commandRunner.containerOrder, extraID) - commandRunner.mutex.Unlock() - if got, err := environment.Observe(context.Background(), containerCount); err != nil || got { - t.Fatalf("container count with extra attempt-owned fixture = %v, %v", got, err) - } - commandRunner.mutex.Lock() - delete(commandRunner.containers, extraID) - commandRunner.mutex.Unlock() - - listed, err := environment.Execute(context.Background(), "docker ps") - if err != nil { - t.Fatal(err) - } - if strings.Contains(listed.Output, "api") || !strings.Contains(listed.Output, "metrics") { - t.Fatalf("running list output:\n%s", listed.Output) - } - listed, err = environment.Execute(context.Background(), "docker container ls --all") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(listed.Output, "api") || !strings.Contains(listed.Output, "metrics") { - t.Fatalf("all list output:\n%s", listed.Output) - } - if strings.Contains(listed.Output, environment.byAlias["api"].id) || strings.Contains(listed.Output, environment.byAlias["api"].actualName) || strings.Contains(listed.Output, testImageReference) { - t.Fatalf("logical list leaked engine identifiers:\n%s", listed.Output) - } - - if _, err := environment.Execute(context.Background(), "docker restart api"); err != nil { - t.Fatal(err) - } - if got, err := environment.Observe(context.Background(), apiRunning); err != nil || !got { - t.Fatalf("api outcome after restart = %v, %v", got, err) - } - inspected, err := environment.Execute(context.Background(), "docker inspect api") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(inspected.Output, `"Name": "api"`) || !strings.Contains(inspected.Output, `"Running": true`) { - t.Fatalf("logical inspect output:\n%s", inspected.Output) - } - if strings.Contains(inspected.Output, environment.byAlias["api"].id) || strings.Contains(inspected.Output, environment.byAlias["api"].actualName) { - t.Fatalf("logical inspect leaked engine identifiers:\n%s", inspected.Output) - } - if _, err := environment.Execute(context.Background(), "docker container stop api"); err != nil { - t.Fatal(err) - } - if got, err := environment.Observe(context.Background(), apiStopped); err != nil || !got { - t.Fatalf("api stopped outcome after stop = %v, %v", got, err) - } -} - -func TestHelpAndCompletionExposeOnlyTeachingSubset(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createTestEnvironment(t, commandRunner) - commandRunner.resetCalls() - - result, err := environment.Execute(context.Background(), "docker --help") - if err != nil { - t.Fatal(err) - } - for _, expected := range []string{"docker ps", "docker start ALIAS", "docker stop ALIAS", "docker inspect ALIAS", "docker logs ALIAS"} { - if !strings.Contains(result.Output, expected) { - t.Errorf("help missing %q:\n%s", expected, result.Output) - } - } - if calls := commandRunner.snapshotCalls(); len(calls) != 0 { - t.Fatalf("help reached Docker transport: %q", calls) - } - - completion := environment.CompletionSource() - if got := completion.CommandNames(); !reflect.DeepEqual(got, []string{"docker", "help"}) { - t.Fatalf("command completion = %q", got) - } - if got := completion.PathCandidates("a"); !reflect.DeepEqual(got, []game.CompletionCandidate{{Value: "api"}}) { - t.Fatalf("alias completion = %#v", got) - } -} - -func TestCloseVerifiesOwnershipAndIsIdempotent(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createTestEnvironment(t, commandRunner) - apiID := environment.byAlias["api"].id - metricsID := environment.byAlias["metrics"].id - commandRunner.resetCalls() - - if err := environment.Close(); err != nil { - t.Fatalf("Close() error = %v", err) - } - want := [][]string{ - {"container", "inspect", "--format", "{{json .}}", metricsID}, - {"container", "rm", "--force", metricsID}, - {"container", "inspect", "--format", "{{json .}}", apiID}, - {"container", "rm", "--force", apiID}, - } - if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { - t.Fatalf("cleanup calls:\n got: %q\nwant: %q", got, want) - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after Close = %d", commandRunner.containerCount()) - } - if err := environment.Close(); err != nil { - t.Fatalf("second Close() error = %v", err) - } - if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { - t.Fatalf("second Close issued more calls: %q", got) - } -} - -func TestCloseRetriesOnlyUnresolvedContainers(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createTestEnvironment(t, commandRunner) - apiID := environment.byAlias["api"].id - metricsID := environment.byAlias["metrics"].id - transientFailure := errors.New("transient remove failure") - commandRunner.failRemoveOnce(metricsID, transientFailure) - commandRunner.resetCalls() - - firstErr := environment.Close() - if firstErr == nil || !strings.Contains(firstErr.Error(), transientFailure.Error()) { - t.Fatalf("first Close() error = %v, want transient failure", firstErr) - } - if commandRunner.containerCount() != 1 { - t.Fatalf("containers after failed Close = %d, want only unresolved fixture", commandRunner.containerCount()) - } - firstCalls := [][]string{ - {"container", "inspect", "--format", "{{json .}}", metricsID}, - {"container", "rm", "--force", metricsID}, - {"container", "inspect", "--format", "{{json .}}", apiID}, - {"container", "rm", "--force", apiID}, - } - if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, firstCalls) { - t.Fatalf("first cleanup calls:\n got: %q\nwant: %q", got, firstCalls) - } - if _, err := environment.Execute(context.Background(), "docker ps"); err == nil || !strings.Contains(err.Error(), "environment is closed") { - t.Fatalf("Execute() after failed cleanup error = %v, want closed environment", err) - } - - if err := environment.Close(); err != nil { - t.Fatalf("retry Close() error = %v", err) - } - want := append(firstCalls, - []string{"container", "inspect", "--format", "{{json .}}", metricsID}, - []string{"container", "rm", "--force", metricsID}, - ) - if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { - t.Fatalf("cleanup calls after retry:\n got: %q\nwant: %q", got, want) - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after retry = %d", commandRunner.containerCount()) - } - if err := environment.Close(); err != nil { - t.Fatalf("completed Close() error = %v", err) - } - if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { - t.Fatalf("completed Close issued more calls: %q", got) - } -} - -func TestCloseTreatsAlreadyMissingContainerAsResolved(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createTestEnvironment(t, commandRunner) - apiID := environment.byAlias["api"].id - metricsID := environment.byAlias["metrics"].id - commandRunner.removeContainer(metricsID) - commandRunner.resetCalls() - - if err := environment.Close(); err != nil { - t.Fatalf("Close() error = %v", err) - } - want := [][]string{ - {"container", "inspect", "--format", "{{json .}}", metricsID}, - {"container", "inspect", "--format", "{{json .}}", apiID}, - {"container", "rm", "--force", apiID}, - } - if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { - t.Fatalf("cleanup calls:\n got: %q\nwant: %q", got, want) - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after Close = %d", commandRunner.containerCount()) - } -} - -func TestCloseRefusesContainerWithMismatchedOwnership(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createTestEnvironment(t, commandRunner) - apiID := environment.byAlias["api"].id - metricsID := environment.byAlias["metrics"].id - commandRunner.corruptLabel(apiID, sessionLabel, "another-session") - commandRunner.resetCalls() - for attempt := 1; attempt <= 2; attempt++ { - if err := environment.Close(); err == nil || !strings.Contains(err.Error(), "ownership labels do not match") { - t.Fatalf("Close() attempt %d error = %v", attempt, err) - } - } - commandRunner.mutex.Lock() - _, apiExists := commandRunner.containers[apiID] - _, metricsExists := commandRunner.containers[metricsID] - commandRunner.mutex.Unlock() - if !apiExists { - t.Fatal("mismatched container was removed") - } - if metricsExists { - t.Fatal("owned container was not removed") - } - want := [][]string{ - {"container", "inspect", "--format", "{{json .}}", metricsID}, - {"container", "rm", "--force", metricsID}, - {"container", "inspect", "--format", "{{json .}}", apiID}, - {"container", "inspect", "--format", "{{json .}}", apiID}, - } - if got := commandRunner.snapshotCalls(); !reflect.DeepEqual(got, want) { - t.Fatalf("protected cleanup calls:\n got: %q\nwant: %q", got, want) - } - - commandRunner.corruptLabel(apiID, sessionLabel, environment.sessionID) - if err := environment.Close(); err != nil { - t.Fatalf("Close() after ownership repair error = %v", err) - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after ownership repair = %d", commandRunner.containerCount()) - } -} - -func TestSetupFailureCleansAlreadyCreatedContainers(t *testing.T) { - t.Run("later create fails", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.failCreateAt = 2 - if _, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()); err == nil { - t.Fatal("Create() unexpectedly succeeded") - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after failed setup = %d", commandRunner.containerCount()) - } - }) - - t.Run("ambiguous create is recovered by owned name", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.failAfterCreateAt = 2 - if _, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()); err == nil { - t.Fatal("Create() unexpectedly succeeded") - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after ambiguous create = %d", commandRunner.containerCount()) - } - calls := commandRunner.snapshotCalls() - ambiguousName := "opsquest-aaaaaaaaaaaaaaaaaaaaaaaa-c02" - foundRecoveryInspect := false - for _, call := range calls { - if reflect.DeepEqual(call, []string{"container", "inspect", "--format", "{{json .}}", ambiguousName}) { - foundRecoveryInspect = true - } - } - if !foundRecoveryInspect { - t.Fatalf("ambiguous create did not reconcile by generated name: %q", calls) - } - }) - - t.Run("transient ambiguous-create inspection remains cleanup eligible", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.failAfterCreateAt = 2 - ambiguousName := "opsquest-aaaaaaaaaaaaaaaaaaaaaaaa-c02" - commandRunner.failNextInspect[ambiguousName] = errors.New("temporary inspect failure") - - partial, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()) - if err == nil || partial == nil { - t.Fatalf("Create() = environment %T, error %v; want partial environment and error", partial, err) - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after retried reconciliation cleanup = %d", commandRunner.containerCount()) - } - if err := partial.Close(); err != nil { - t.Fatalf("Close() after setup cleanup = %v", err) - } - }) - - t.Run("delayed ambiguous create remains cleanup eligible", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.failAfterCreateAt = 2 - ambiguousName := "opsquest-aaaaaaaaaaaaaaaaaaaaaaaa-c02" - commandRunner.hideNextInspect[ambiguousName] = true - - partial, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()) - if err == nil || partial == nil { - t.Fatalf("Create() = environment %T, error %v; want partial environment and error", partial, err) - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after delayed reconciliation cleanup = %d", commandRunner.containerCount()) - } - if err := partial.Close(); err != nil { - t.Fatalf("Close() after setup cleanup = %v", err) - } - }) - - t.Run("fixture start fails", func(t *testing.T) { - commandRunner := newFakeDockerRunner() - commandRunner.failNextStart = true - if _, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()); err == nil { - t.Fatal("Create() unexpectedly succeeded") - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after failed start = %d", commandRunner.containerCount()) - } - }) -} - -func TestConcurrentSessionsCleanOnlyTheirOwnContainers(t *testing.T) { - commandRunner := newFakeDockerRunner() - sessions := []string{strings.Repeat("a", 24), strings.Repeat("d", 24)} - index := 0 - factory := newFactory(game.SandboxFactory{}, commandRunner, nil, func() (string, error) { - value := sessions[index] - index++ - return value, nil - }) - firstCreated, err := factory.Create(context.Background(), testDockerMission()) - if err != nil { - t.Fatal(err) - } - secondCreated, err := factory.Create(context.Background(), testDockerMission()) - if err != nil { - t.Fatal(err) - } - first := firstCreated.(*environment) - second := secondCreated.(*environment) - if err := first.Close(); err != nil { - t.Fatal(err) - } - if commandRunner.containerCount() != 2 { - t.Fatalf("first cleanup affected second session; remaining = %d", commandRunner.containerCount()) - } - for _, tracked := range second.snapshotContainers() { - commandRunner.mutex.Lock() - container := commandRunner.containers[tracked.id] - commandRunner.mutex.Unlock() - if container == nil || container.labels[sessionLabel] != sessions[1] { - t.Fatalf("second-session fixture %s was changed", tracked.alias) - } - } - if err := second.Close(); err != nil { - t.Fatal(err) - } - if commandRunner.containerCount() != 0 { - t.Fatalf("containers after both closes = %d", commandRunner.containerCount()) - } -} - -type fallbackEnvironment struct{} - -func (fallbackEnvironment) PromptLabel() string { return "/fallback" } -func (fallbackEnvironment) Execute(context.Context, string) (game.Execution, error) { - return game.Execution{}, nil -} -func (fallbackEnvironment) Observe(context.Context, mission.Condition) (bool, error) { - return false, nil -} -func (fallbackEnvironment) CompletionSource() game.CompletionSource { return fallbackCompletion{} } -func (fallbackEnvironment) Close() error { return nil } - -type fallbackCompletion struct{} - -func (fallbackCompletion) CommandNames() []string { return nil } -func (fallbackCompletion) PathCandidates(string) []game.CompletionCandidate { - return nil -} - -func TestFactoryDelegatesSimulatedMissionsWithoutDocker(t *testing.T) { - called := false - fallback := game.FactoryFunc(func(_ context.Context, item mission.Mission) (game.Environment, error) { - called = true - return fallbackEnvironment{}, nil - }) - factory := newFactory(fallback, nil, execNotFoundError{}, nil) - created, err := factory.Create(context.Background(), mission.Mission{ID: "linux", Environment: mission.EnvironmentSimulated}) - if err != nil { - t.Fatal(err) - } - if !called || created.PromptLabel() != "/fallback" { - t.Fatalf("fallback called = %v, environment = %#v", called, created) - } -} - -func TestInvalidDockerSetupNeverReachesRunner(t *testing.T) { - commandRunner := newFakeDockerRunner() - item := testDockerMission() - item.Docker.Images[0].Reference = "--privileged" - availability := testFactory(commandRunner).Availability(context.Background(), item) - if availability.Available || !strings.Contains(availability.Detail, "pinned by sha256 digest") { - t.Fatalf("availability = %#v", availability) - } - if len(commandRunner.snapshotCalls()) != 0 { - t.Fatalf("invalid setup reached Docker runner: %q", commandRunner.snapshotCalls()) - } -} - -func TestRuntimeUsesCatalogDockerNameRules(t *testing.T) { - item := testDockerMission() - item.Docker.Images[0].Alias = "fixture.v1" - item.Docker.Containers[0].Image = "fixture.v1" - item.Docker.Containers[1].Image = "fixture.v1" - item.Docker.Containers[0].Name = "api_worker" - if err := mission.ValidateDockerSetup(*item.Docker); err != nil { - t.Fatalf("catalog-valid Docker names were rejected at runtime: %v", err) - } - if action, err := parseAction("docker start api_worker"); err != nil || action.alias != "api_worker" { - t.Fatalf("parse catalog-valid alias = %#v, %v", action, err) - } -} - -func TestLimitedBufferCapsCapturedOutput(t *testing.T) { - buffer := newLimitedBuffer(4) - if count, err := buffer.Write([]byte("abcdef")); err != nil || count != 6 { - t.Fatalf("Write() = %d, %v", count, err) - } - if got := buffer.String(); got != "abcd" || !buffer.exceeded { - t.Fatalf("limited buffer = %q, exceeded=%v", got, buffer.exceeded) - } -} diff --git a/internal/dockerlab/environment.go b/internal/dockerlab/environment.go index 0ffb2df..336803d 100644 --- a/internal/dockerlab/environment.go +++ b/internal/dockerlab/environment.go @@ -2,31 +2,14 @@ package dockerlab import ( "context" - "encoding/json" - "errors" "fmt" - "sort" - "strconv" - "strings" "sync" "time" "github.com/aleksandergregersen/opsquest/internal/game" - "github.com/aleksandergregersen/opsquest/internal/mission" ) const ( - managedLabel = "com.opsquest.managed" - schemaLabel = "com.opsquest.schema" - sessionLabel = "com.opsquest.session" - missionLabel = "com.opsquest.mission" - aliasLabel = "com.opsquest.alias" - // Owner labels let a later process recognize fixtures whose OpsQuest - // process exited without cleanup. They are advisory: removal still - // requires every ownership label above to match. - ownerPIDLabel = "com.opsquest.owner-pid" - ownerHostLabel = "com.opsquest.owner-host" - // fixtureLifetime bounds every long-lived fixture process, so a fixture // orphaned by an abnormal exit stops consuming resources within a day. fixtureLifetime = 24 * time.Hour @@ -53,8 +36,7 @@ type environment struct { runner runner sessionID string missionID string - ownerPID int - ownerHost string + owner processOwner pollInterval time.Duration readyTimeout time.Duration containers []*trackedContainer @@ -81,10 +63,6 @@ func (e *environment) PromptLabel() string { return "docker" } -func (e *environment) CompletionSource() game.CompletionSource { - return dockerCompletion{environment: e} -} - func (e *environment) Execute(ctx context.Context, line string) (game.Execution, error) { if err := e.ready(ctx); err != nil { return game.Execution{}, err @@ -93,741 +71,54 @@ func (e *environment) Execute(ctx context.Context, line string) (game.Execution, if err != nil { return game.Execution{}, err } - result := game.Execution{PipelineWidth: 1} + if action.kind == actionHelp { + return game.Execution{Output: dockerHelp, PracticedCommands: []string{"help"}, PipelineWidth: 1}, nil + } + output, err := e.perform(ctx, action) + return game.Execution{Output: output, PracticedCommands: []string{"docker"}, PipelineWidth: 1}, err +} + +// perform runs one parsed Docker action and returns its terminal output. +func (e *environment) perform(ctx context.Context, action dockerAction) (string, error) { switch action.kind { - case actionHelp: - result.Output = dockerHelp - result.PracticedCommands = []string{"help"} case actionList: - result.Output, err = e.listContainers(ctx, action.all, action.filters) - result.PracticedCommands = []string{"docker"} + return e.listContainers(ctx, action.all, action.filters) case actionStart: - err = e.startContainer(ctx, action.alias) - if err == nil { - result.Output = action.alias + "\n" - } - result.PracticedCommands = []string{"docker"} + return echo(action.alias, e.startContainer(ctx, action.alias)) case actionRestart: - err = e.restartContainer(ctx, action.alias) - if err == nil { - result.Output = action.alias + "\n" - } - result.PracticedCommands = []string{"docker"} + return echo(action.alias, e.restartContainer(ctx, action.alias)) case actionStop: - err = e.stopContainer(ctx, action.alias) - if err == nil { - result.Output = action.alias + "\n" - } - result.PracticedCommands = []string{"docker"} + return echo(action.alias, e.stopContainer(ctx, action.alias)) case actionRemove: - err = e.removeContainer(ctx, action.alias) - if err == nil { - result.Output = action.alias + "\n" - } - result.PracticedCommands = []string{"docker"} + return echo(action.alias, e.removeContainer(ctx, action.alias)) case actionInspect: - result.Output, err = e.logicalInspect(ctx, action.alias) - result.PracticedCommands = []string{"docker"} + return e.logicalInspect(ctx, action.alias) case actionLogs: - result.Output, err = e.containerLogs(ctx, action.alias, action.tail) - result.PracticedCommands = []string{"docker"} + return e.containerLogs(ctx, action.alias, action.tail) case actionNetworkList: - result.Output, err = e.listNetworks(ctx) - result.PracticedCommands = []string{"docker"} + return e.listNetworks(ctx) case actionNetworkCreate: - result.Output, err = e.createPlayerNetwork(ctx, action.network) - result.PracticedCommands = []string{"docker"} + return e.createPlayerNetwork(ctx, action.network) case actionNetworkRemove: - err = e.removeNetwork(ctx, action.network) - if err == nil { - result.Output = action.network + "\n" - } - result.PracticedCommands = []string{"docker"} + return echo(action.network, e.removeNetwork(ctx, action.network)) case actionNetworkInspect: - result.Output, err = e.logicalNetworkInspect(ctx, action.network) - result.PracticedCommands = []string{"docker"} + return e.logicalNetworkInspect(ctx, action.network) case actionNetworkConnect: - err = e.connectNetwork(ctx, action.network, action.alias) - result.PracticedCommands = []string{"docker"} + return "", e.connectNetwork(ctx, action.network, action.alias) case actionNetworkDisconnect: - err = e.disconnectNetwork(ctx, action.network, action.alias) - result.PracticedCommands = []string{"docker"} + return "", e.disconnectNetwork(ctx, action.network, action.alias) default: - err = fmt.Errorf("unsupported Docker action") - } - return result, err -} - -func (e *environment) Observe(ctx context.Context, condition mission.Condition) (bool, error) { - if err := e.ready(ctx); err != nil { - return false, err - } - switch condition.Type { - case mission.ConditionDockerContainerRunning: - tracked, exists := e.container(condition.Container) - if !exists { - return false, nil - } - inspection, exists, err := e.inspect(ctx, tracked.id) - return exists && inspection.State.Running, err - case mission.ConditionDockerContainerStopped: - tracked, exists := e.container(condition.Container) - if !exists { - return false, nil - } - inspection, exists, err := e.inspect(ctx, tracked.id) - return exists && !inspection.State.Running, err - case mission.ConditionDockerContainerAbsent: - tracked, exists := e.container(condition.Container) - if !exists { - return false, nil - } - _, exists, err := e.inspect(ctx, tracked.id) - return err == nil && !exists, err - case mission.ConditionDockerNetworkShared, mission.ConditionDockerNetworkIsolated: - if len(condition.Containers) != 2 { - return false, fmt.Errorf("%s requires exactly two containers", condition.Type) - } - shared, bothExist, err := e.shareNetwork(ctx, condition.Containers[0], condition.Containers[1]) - if err != nil || !bothExist { - return false, err - } - return shared == (condition.Type == mission.ConditionDockerNetworkShared), nil - case mission.ConditionDockerNetworkAbsent: - return e.networkAbsent(ctx, condition.Network) - case mission.ConditionDockerContainerCountEqual: - if condition.Count == nil { - return false, fmt.Errorf("docker_container_count_equals requires count") - } - count, err := e.countOwnedContainers(ctx) - if err != nil { - return false, err - } - return count == *condition.Count, nil - default: - return false, fmt.Errorf("unknown validation type %q for Docker environment", condition.Type) - } -} - -func (e *environment) Close() error { - e.cleanupMutex.Lock() - defer e.cleanupMutex.Unlock() - - e.mutex.Lock() - if e.closed { - e.mutex.Unlock() - return nil - } - if !e.closing { - e.closing = true - e.cleanupPending = append([]*trackedContainer(nil), e.containers...) - e.networksPending = append([]*trackedNetwork(nil), e.networks...) - } - containers := append([]*trackedContainer(nil), e.cleanupPending...) - networks := append([]*trackedNetwork(nil), e.networksPending...) - e.mutex.Unlock() - - ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) - defer cancel() - var cleanupErrors []error - unresolved := make([]*trackedContainer, 0, len(containers)) - for index := len(containers) - 1; index >= 0; index-- { - tracked := containers[index] - inspection, exists, err := e.inspectTrackedForCleanup(ctx, tracked) - if err != nil { - cleanupErrors = append(cleanupErrors, fmt.Errorf("inspect Docker fixture %s before cleanup: %w", tracked.alias, err)) - unresolved = append(unresolved, tracked) - continue - } - if !exists { - continue - } - labels := inspection.Config.Labels - if labels[managedLabel] != "true" || labels[schemaLabel] != "1" || labels[sessionLabel] != e.sessionID || labels[missionLabel] != e.missionID || labels[aliasLabel] != tracked.alias { - cleanupErrors = append(cleanupErrors, fmt.Errorf("refusing to remove Docker fixture %s because its ownership labels do not match", tracked.alias)) - unresolved = append(unresolved, tracked) - continue - } - tracked.id = inspection.ID - if _, err := e.run(ctx, "container", "rm", "--force", inspection.ID); err != nil { - cleanupErrors = append(cleanupErrors, fmt.Errorf("remove Docker fixture %s: %w", tracked.alias, err)) - unresolved = append(unresolved, tracked) - } - } - - // Networks go last: Docker refuses to remove a network while any of - // its containers still exist. - unresolvedNetworks, networkErrors := e.closeNetworks(ctx, networks) - cleanupErrors = append(cleanupErrors, networkErrors...) - - e.mutex.Lock() - e.cleanupPending = unresolved - e.networksPending = unresolvedNetworks - e.closed = len(unresolved) == 0 && len(unresolvedNetworks) == 0 - e.mutex.Unlock() - return errors.Join(cleanupErrors...) -} - -func (e *environment) inspectTrackedForCleanup(ctx context.Context, tracked *trackedContainer) (containerInspection, bool, error) { - if tracked.id != "" { - return e.inspectUnchecked(ctx, tracked.id) - } - inspection, exists, err := e.inspectReferenceUnchecked(ctx, tracked.actualName) - if err != nil || !exists { - return inspection, exists, err - } - if !containerIDPattern.MatchString(inspection.ID) { - return containerInspection{}, false, fmt.Errorf("Docker inspection returned an invalid container ID") - } - return inspection, true, nil -} - -func (e *environment) createContainer(ctx context.Context, index int, fixture mission.DockerContainerSpec, reference string) (*trackedContainer, error) { - actualName := fmt.Sprintf("opsquest-%s-c%02d", e.sessionID, index+1) - tracked := &trackedContainer{ - logicalID: fmt.Sprintf("lab-%02d", index+1), - alias: fixture.Name, - imageAlias: fixture.Image, - actualName: actualName, - } - args := []string{ - "container", "create", - "--pull", "never", - "--name", actualName, - "--label", managedLabel + "=true", - "--label", schemaLabel + "=1", - "--label", sessionLabel + "=" + e.sessionID, - "--label", missionLabel + "=" + e.missionID, - "--label", aliasLabel + "=" + fixture.Name, - } - if e.ownerHost != "" && e.ownerPID > 0 { - args = append(args, - "--label", ownerPIDLabel+"="+strconv.Itoa(e.ownerPID), - "--label", ownerHostLabel+"="+e.ownerHost, - ) - } - restartPolicy := "no" - if fixture.Restart == mission.DockerRestartOnFailure { - restartPolicy = fmt.Sprintf("on-failure:%d", crashLoopMaxRetries) - } - // A fixture without declared networks keeps networking disabled. One - // with networks starts on the first attempt-owned internal network and - // joins the rest before it starts. - networkArgs := []string{"--network", "none"} - var joined []*trackedNetwork - for _, name := range fixture.Networks { - network, exists := e.network(name) - if !exists || network.id == "" { - return nil, fmt.Errorf("create Docker fixture %s: network %q was not created", fixture.Name, name) - } - joined = append(joined, network) - } - if len(joined) > 0 { - networkArgs = []string{"--network", joined[0].id, "--network-alias", fixture.Name} - } - args = append(args, networkArgs...) - args = append(args, - "--read-only", - "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=8m", - "--user", "65532:65532", - "--cap-drop", "ALL", - "--security-opt", "no-new-privileges", - "--pids-limit", "64", - "--memory", "128m", - "--memory-swap", "128m", - "--cpus", "0.5", - "--ulimit", "nofile=256:256", - "--restart", restartPolicy, - "--stop-timeout", "1", - ) - if probe, configured := healthProbes[fixture.Health]; configured { - // The probe is a fixed BusyBox applet selected by a validated enum; - // mission content and player input never supply the probe command. - args = append(args, - "--health-cmd", probe, - "--health-interval", "1s", - "--health-timeout", "1s", - "--health-retries", "1", - ) - } - lifetime := strconv.Itoa(int(fixtureLifetime / time.Second)) - switch { - case fixture.ExitCode != nil: - args = append(args, - "--entrypoint", "/bin/sh", - reference, - "-c", `printf '%s\n' "$1"; exit "$2"`, - "opsquest-diagnostic", fixture.Log, strconv.Itoa(*fixture.ExitCode), - ) - case fixture.Log != "": - args = append(args, - "--entrypoint", "/bin/sh", - reference, - "-c", `printf '%s\n' "$1"; exec sleep "$2"`, - "opsquest-service", fixture.Log, lifetime, - ) - default: - args = append(args, - "--entrypoint", "/bin/sleep", - reference, - lifetime, - ) - } - result, err := e.run(ctx, args...) - if err != nil { - createErr := fmt.Errorf("create Docker fixture %s: %w", fixture.Name, err) - return e.reconcileAmbiguousCreate(tracked, createErr) - } - id := strings.TrimSpace(result.stdout) - if !containerIDPattern.MatchString(id) { - createErr := fmt.Errorf("create Docker fixture %s: Docker returned an invalid container ID", fixture.Name) - return e.reconcileAmbiguousCreate(tracked, createErr) - } - tracked.id = id - for _, network := range joined[min(1, len(joined)):] { - if _, err := e.run(ctx, "network", "connect", "--alias", fixture.Name, network.id, id); err != nil { - return tracked, fmt.Errorf("connect Docker fixture %s to network %s: %w", fixture.Name, network.name, err) - } + return "", fmt.Errorf("unsupported Docker action") } - return tracked, nil } -// reconcileAmbiguousCreate handles the case where the Docker CLI reports an -// error or malformed output after the daemon may already have created the -// container. The generated name is internal, and ownership labels must match -// before the recovered exact ID is admitted to the cleanup set. -func (e *environment) reconcileAmbiguousCreate(tracked *trackedContainer, createErr error) (*trackedContainer, error) { - ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) - defer cancel() - inspection, exists, err := e.inspectReferenceUnchecked(ctx, tracked.actualName) - if err != nil { - // The create may have reached the daemon even though reconciliation is - // temporarily unavailable. Retain the generated name so cleanup can - // safely re-inspect ownership before removing anything. - return tracked, errors.Join(createErr, fmt.Errorf("reconcile Docker fixture %s: %w", tracked.alias, err)) - } - if !exists { - // A successful create can become visible just after a transient missing - // result. Keep the generated name in the cleanup set so Close performs - // one more ownership-checked inspection before considering it absent. - return tracked, createErr - } - labels := inspection.Config.Labels - if labels[managedLabel] != "true" || labels[sessionLabel] != e.sessionID || labels[missionLabel] != e.missionID || labels[aliasLabel] != tracked.alias { - return nil, errors.Join(createErr, fmt.Errorf("refusing to recover Docker fixture %s because its ownership labels do not match", tracked.alias)) - } - if !containerIDPattern.MatchString(inspection.ID) { - return nil, errors.Join(createErr, fmt.Errorf("reconcile Docker fixture %s: Docker returned an invalid container ID", tracked.alias)) - } - tracked.id = inspection.ID - return tracked, createErr -} - -// healthProbes maps a fixture health enum to a fixed probe command. -var healthProbes = map[string]string{ - mission.DockerHealthHealthy: "true", - mission.DockerHealthUnhealthy: "false", -} - -func (e *environment) startContainer(ctx context.Context, alias string) error { - _, err := e.runOnAlias(ctx, alias, "container", "start") - return err -} - -func (e *environment) restartContainer(ctx context.Context, alias string) error { - _, err := e.runOnAlias(ctx, alias, "container", "restart") - return err -} - -func (e *environment) stopContainer(ctx context.Context, alias string) error { - _, err := e.runOnAlias(ctx, alias, "container", "stop") - return err -} - -// removeContainer deletes one stopped mission container. Running containers -// are refused rather than forced so the lesson keeps stop and remove distinct. -func (e *environment) removeContainer(ctx context.Context, alias string) error { - tracked, exists := e.container(alias) - if !exists { - return fmt.Errorf("docker: container %q is not part of this mission", alias) - } - inspection, exists, err := e.inspect(ctx, tracked.id) - if err != nil { - return err - } - if !exists { - return fmt.Errorf("docker: container %q has already been removed", alias) - } - if inspection.State.Running { - return fmt.Errorf("docker: cannot remove running container %q; stop it first", alias) - } - _, err = e.runOnAlias(ctx, alias, "container", "rm") - return err -} - -// runOnAlias runs one fixed Docker action against the exact container ID -// behind alias. Engine errors about a missing container are translated so -// removed fixtures never reveal their real container ID. -func (e *environment) runOnAlias(ctx context.Context, alias string, args ...string) (runResult, error) { - tracked, exists := e.container(alias) - if !exists { - return runResult{}, fmt.Errorf("docker: container %q is not part of this mission", alias) - } - operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) - defer cancel() - result, err := e.runner.run(operationCtx, append(args, tracked.id)...) - if err != nil { - if isMissingContainer(result) { - return result, fmt.Errorf("docker: container %q has been removed", alias) - } - return result, fmt.Errorf("Docker command failed%s", redactIdentifiers(dockerFailureDetail(result, err), tracked)) - } - return result, nil -} - -func (e *environment) waitContainer(ctx context.Context, alias string, expectedExitCode int) error { - result, err := e.runOnAlias(ctx, alias, "container", "wait") - if err != nil { - return err - } - exitCode, err := strconv.Atoi(strings.TrimSpace(result.stdout)) - if err != nil || exitCode != expectedExitCode { - return fmt.Errorf("Docker fixture %s exited with unexpected status %q", alias, strings.TrimSpace(result.stdout)) - } - return nil -} - -// waitForFixture polls sanitized inspection until ready reports true. Setup -// uses it for fixtures whose teaching state (a health verdict or a visible -// crash loop) appears shortly after start. -func (e *environment) waitForFixture(ctx context.Context, alias string, ready func(containerInspection) bool) error { - tracked, exists := e.container(alias) - if !exists { - return fmt.Errorf("docker: container %q is not part of this mission", alias) - } - interval := e.pollInterval - if interval <= 0 { - interval = fixtureReadyInterval - } - timeout := e.readyTimeout - if timeout <= 0 { - timeout = fixtureReadyTimeout - } - deadline := time.NewTimer(timeout) - defer deadline.Stop() - for { - inspection, exists, err := e.inspect(ctx, tracked.id) - if err != nil { - return err - } - if !exists { - return fmt.Errorf("Docker fixture %s disappeared during setup", alias) - } - if ready(inspection) { - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-deadline.C: - return fmt.Errorf("Docker fixture %s did not reach its teaching state within %s", alias, timeout) - case <-time.After(interval): - } - } -} - -func (e *environment) containerLogs(ctx context.Context, alias string, tail int) (string, error) { - args := []string{"container", "logs"} - if tail >= 0 { - args = append(args, "--tail", strconv.Itoa(tail)) - } - result, err := e.runOnAlias(ctx, alias, args...) +// echo prints the target name after a successful command, as Docker does for +// lifecycle and removal commands. +func echo(name string, err error) (string, error) { if err != nil { return "", err } - return result.stdout, nil -} - -func (e *environment) listContainers(ctx context.Context, all bool, filters []listFilter) (string, error) { - type row struct { - logicalID string - image string - status string - alias string - } - rows := make([]row, 0, len(e.containers)) - // Eight columns preserves the original listing layout for plain states. - statusWidth := 8 - for _, tracked := range e.snapshotContainers() { - inspection, exists, err := e.inspect(ctx, tracked.id) - if err != nil { - return "", err - } - // A status filter selects stopped containers even without --all, - // matching docker ps. - if !exists || !all && !hasFilterKey(filters, "status") && !inspection.State.Running || !matchesFilters(inspection, filters) { - continue - } - status := displayStatus(inspection) - statusWidth = max(statusWidth, len(status)) - rows = append(rows, row{logicalID: tracked.logicalID, image: tracked.imageAlias, status: status, alias: tracked.alias}) - } - var output strings.Builder - fmt.Fprintf(&output, "%-13s %-9s %-*s %s\n", "CONTAINER ID", "IMAGE", statusWidth, "STATUS", "NAMES") - for _, item := range rows { - fmt.Fprintf(&output, "%-13s %-9s %-*s %s\n", item.logicalID, item.image, statusWidth, item.status, item.alias) - } - return output.String(), nil -} - -func displayStatus(inspection containerInspection) string { - status := inspection.State.Status - if status == "" { - if inspection.State.Running { - status = "running" - } else { - status = "stopped" - } - } - if health := healthStatus(inspection); inspection.State.Running && health != "none" { - status += " (" + health + ")" - } - return status -} - -// healthStatus reports the probe verdict, or none for a fixture without a -// health probe. Like docker ps, listings only show it for running containers. -func healthStatus(inspection containerInspection) string { - if inspection.State.Health == nil || inspection.State.Health.Status == "" { - return "none" - } - return inspection.State.Health.Status -} - -func hasFilterKey(filters []listFilter, key string) bool { - for _, filter := range filters { - if filter.key == key { - return true - } - } - return false -} - -// matchesFilters follows docker ps semantics: values for one key are -// alternatives, and different keys must all match. -func matchesFilters(inspection containerInspection, filters []listFilter) bool { - matched := make(map[string]bool, len(filters)) - for _, filter := range filters { - if _, seen := matched[filter.key]; !seen { - matched[filter.key] = false - } - var actual string - switch filter.key { - case "status": - actual = inspection.State.Status - case "health": - actual = "none" - if inspection.State.Running { - actual = healthStatus(inspection) - } - } - if actual == filter.value { - matched[filter.key] = true - } - } - for _, ok := range matched { - if !ok { - return false - } - } - return true -} - -func (e *environment) logicalInspect(ctx context.Context, alias string) (string, error) { - tracked, exists := e.container(alias) - if !exists { - return "", fmt.Errorf("docker: container %q is not part of this mission", alias) - } - inspection, exists, err := e.inspect(ctx, tracked.id) - if err != nil { - return "", err - } - if !exists { - return "", fmt.Errorf("docker: container %q no longer exists", alias) - } - type logicalHealth struct { - Status string `json:"Status"` - } - logical := struct { - ID string `json:"Id"` - Name string `json:"Name"` - Image string `json:"Image"` - RestartCount int `json:"RestartCount"` - State struct { - Running bool `json:"Running"` - Restarting bool `json:"Restarting"` - Status string `json:"Status"` - ExitCode int `json:"ExitCode"` - Health *logicalHealth `json:"Health,omitempty"` - } `json:"State"` - HostConfig struct { - RestartPolicy restartPolicy `json:"RestartPolicy"` - } `json:"HostConfig"` - NetworkSettings struct { - Networks []string `json:"Networks"` - } `json:"NetworkSettings"` - }{ID: tracked.logicalID, Name: tracked.alias, Image: tracked.imageAlias, RestartCount: inspection.RestartCount} - logical.State.Running = inspection.State.Running - logical.State.Restarting = inspection.State.Restarting - logical.State.Status = inspection.State.Status - logical.State.ExitCode = inspection.State.ExitCode - if health := healthStatus(inspection); health != "none" { - logical.State.Health = &logicalHealth{Status: health} - } - logical.NetworkSettings.Networks = e.logicalNetworks(inspection) - logical.HostConfig.RestartPolicy = inspection.HostConfig.RestartPolicy - if logical.HostConfig.RestartPolicy.Name == "" { - logical.HostConfig.RestartPolicy.Name = "no" - } - encoded, err := json.MarshalIndent(logical, "", " ") - if err != nil { - return "", err - } - return string(encoded) + "\n", nil -} - -type restartPolicy struct { - Name string `json:"Name"` - MaximumRetryCount int `json:"MaximumRetryCount"` -} - -type healthState struct { - Status string `json:"Status"` -} - -type containerInspection struct { - ID string `json:"Id"` - Name string `json:"Name"` - Created string `json:"Created"` - RestartCount int `json:"RestartCount"` - Config struct { - Labels map[string]string `json:"Labels"` - } `json:"Config"` - HostConfig struct { - RestartPolicy restartPolicy `json:"RestartPolicy"` - } `json:"HostConfig"` - State struct { - Running bool `json:"Running"` - Restarting bool `json:"Restarting"` - Status string `json:"Status"` - ExitCode int `json:"ExitCode"` - Health *healthState `json:"Health"` - } `json:"State"` - NetworkSettings struct { - // Networks is keyed by the engine network name. NetworkID can be - // empty before a container first starts, so both identify a network. - Networks map[string]endpointState `json:"Networks"` - } `json:"NetworkSettings"` -} - -type endpointState struct { - NetworkID string `json:"NetworkID"` -} - -func (e *environment) inspect(ctx context.Context, id string) (containerInspection, bool, error) { - if err := e.ready(ctx); err != nil { - return containerInspection{}, false, err - } - return e.inspectUnchecked(ctx, id) -} - -func (e *environment) inspectUnchecked(ctx context.Context, id string) (containerInspection, bool, error) { - inspection, exists, err := e.inspectReferenceUnchecked(ctx, id) - if err != nil || !exists { - return inspection, exists, err - } - if inspection.ID != id { - return containerInspection{}, false, fmt.Errorf("Docker inspection returned an unexpected container ID") - } - return inspection, true, nil -} - -func (e *environment) inspectReferenceUnchecked(ctx context.Context, reference string) (containerInspection, bool, error) { - return inspectReference(ctx, e.runner, reference) -} - -// inspectReference inspects one exact container ID or generated name. It is -// shared by attempt environments and the orphan janitor. -func inspectReference(ctx context.Context, commandRunner runner, reference string) (containerInspection, bool, error) { - result, err := runDocker(ctx, commandRunner, "container", "inspect", "--format", "{{json .}}", reference) - if err != nil { - if isMissingContainer(result) { - return containerInspection{}, false, nil - } - return containerInspection{}, false, err - } - var inspection containerInspection - if err := json.Unmarshal([]byte(result.stdout), &inspection); err != nil { - return containerInspection{}, false, fmt.Errorf("decode Docker inspection: %w", err) - } - return inspection, true, nil -} - -func (e *environment) countOwnedContainers(ctx context.Context) (int, error) { - result, err := e.run(ctx, - "container", "ls", "--all", "--quiet", "--no-trunc", - "--filter", "label="+sessionLabel+"="+e.sessionID, - ) - if err != nil { - return 0, err - } - ids := strings.Fields(result.stdout) - count := 0 - for _, id := range ids { - if !containerIDPattern.MatchString(id) { - return 0, fmt.Errorf("Docker container listing returned an invalid container ID") - } - inspection, exists, err := e.inspect(ctx, id) - if err != nil { - return 0, err - } - if !exists { - continue - } - labels := inspection.Config.Labels - if labels[managedLabel] == "true" && labels[schemaLabel] == "1" && labels[sessionLabel] == e.sessionID && labels[missionLabel] == e.missionID { - count++ - } - } - return count, nil -} - -func (e *environment) run(ctx context.Context, args ...string) (runResult, error) { - return runDocker(ctx, e.runner, args...) -} - -// runDocker runs one fixed Docker invocation with the standard operation -// timeout and a bounded error summary. -func runDocker(ctx context.Context, commandRunner runner, args ...string) (runResult, error) { - operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) - defer cancel() - result, err := commandRunner.run(operationCtx, args...) - if err != nil { - return result, fmt.Errorf("Docker command failed%s", dockerFailureDetail(result, err)) - } - return result, nil -} - -// redactIdentifiers replaces a tracked container's engine ID and generated -// name in player-visible error text with its logical identity. -func redactIdentifiers(message string, tracked *trackedContainer) string { - if tracked.id != "" { - message = strings.ReplaceAll(message, tracked.id, tracked.logicalID) - if len(tracked.id) >= 12 { - message = strings.ReplaceAll(message, tracked.id[:12], tracked.logicalID) - } - } - return strings.ReplaceAll(message, tracked.actualName, tracked.alias) + return name + "\n", nil } func (e *environment) ready(ctx context.Context) error { @@ -854,38 +145,3 @@ func (e *environment) snapshotContainers() []*trackedContainer { defer e.mutex.RUnlock() return append([]*trackedContainer(nil), e.containers...) } - -func isMissingContainer(result runResult) bool { - message := strings.ToLower(result.stderr + "\n" + result.stdout) - return strings.Contains(message, "no such container") || strings.Contains(message, "no such object") -} - -type dockerCompletion struct { - environment *environment -} - -func (dockerCompletion) CommandNames() []string { - return []string{"docker", "help"} -} - -func (c dockerCompletion) PathCandidates(prefix string) []game.CompletionCandidate { - values := []string{"--all", "--filter", "--tail", "-a", "connect", "container", "create", "disconnect", "inspect", "logs", "ls", "network", "ps", "restart", "rm", "start", "stop"} - for _, tracked := range c.environment.snapshotContainers() { - values = append(values, tracked.alias) - } - seen := make(map[string]bool) - for _, network := range c.environment.snapshotNetworks() { - if !seen[network.name] { - seen[network.name] = true - values = append(values, network.name) - } - } - sort.Strings(values) - candidates := make([]game.CompletionCandidate, 0) - for _, value := range values { - if strings.HasPrefix(value, prefix) { - candidates = append(candidates, game.CompletionCandidate{Value: value}) - } - } - return candidates -} diff --git a/internal/dockerlab/factory.go b/internal/dockerlab/factory.go index 54258d8..3028ba9 100644 --- a/internal/dockerlab/factory.go +++ b/internal/dockerlab/factory.go @@ -6,10 +6,8 @@ import ( "encoding/hex" "errors" "fmt" - "os" "os/exec" "regexp" - "strings" "time" "github.com/aleksandergregersen/opsquest/internal/game" @@ -40,23 +38,6 @@ type Factory struct { readyTimeout time.Duration } -// processOwner identifies the current OpsQuest process for orphan detection -// and decides whether another recorded owner process is still alive. -type processOwner struct { - pid int - host string - alive func(int) bool - now func() time.Time -} - -func currentProcessOwner() processOwner { - host, err := os.Hostname() - if err != nil { - host = "" - } - return processOwner{pid: os.Getpid(), host: host, alive: processAlive, now: time.Now} -} - var ( _ game.Factory = (*Factory)(nil) _ game.AvailabilityChecker = (*Factory)(nil) @@ -128,168 +109,18 @@ func (f *Factory) Create(ctx context.Context, item mission.Mission) (game.Enviro runner: f.runner, sessionID: sessionID, missionID: item.ID, - ownerPID: f.owner.pid, - ownerHost: f.owner.host, + owner: f.owner, pollInterval: f.pollInterval, readyTimeout: f.readyTimeout, byAlias: make(map[string]*trackedContainer), networkByName: make(map[string]*trackedNetwork), } - for _, network := range item.Docker.Networks { - if _, err := environment.createNetwork(ctx, network.Name, false); err != nil { - return environment, joinSetupCleanupError(err, environment.Close()) - } - } - images := make(map[string]string, len(item.Docker.Images)) - for _, image := range item.Docker.Images { - images[image.Alias] = image.Reference - } - for index, fixture := range item.Docker.Containers { - tracked, createErr := environment.createContainer(ctx, index, fixture, images[fixture.Image]) - if tracked != nil { - environment.containers = append(environment.containers, tracked) - environment.byAlias[tracked.alias] = tracked - } - if createErr != nil { - return environment, joinSetupCleanupError(createErr, environment.Close()) - } - } - for _, fixture := range item.Docker.Containers { - if fixture.Restart == mission.DockerRestartOnFailure { - if err := environment.startContainer(ctx, fixture.Name); err != nil { - return environment, joinSetupCleanupError(fmt.Errorf("start crash-loop Docker fixture %s: %w", fixture.Name, err), environment.Close()) - } - looping := func(inspection containerInspection) bool { - return inspection.RestartCount >= crashLoopReadyRestarts - } - if err := environment.waitForFixture(ctx, fixture.Name, looping); err != nil { - return environment, joinSetupCleanupError(fmt.Errorf("wait for crash-loop Docker fixture %s: %w", fixture.Name, err), environment.Close()) - } - continue - } - if fixture.ExitCode != nil { - if err := environment.startContainer(ctx, fixture.Name); err != nil { - return environment, joinSetupCleanupError(fmt.Errorf("start diagnostic Docker fixture %s: %w", fixture.Name, err), environment.Close()) - } - if err := environment.waitContainer(ctx, fixture.Name, *fixture.ExitCode); err != nil { - return environment, joinSetupCleanupError(fmt.Errorf("wait for diagnostic Docker fixture %s: %w", fixture.Name, err), environment.Close()) - } - continue - } - if fixture.State != mission.DockerStateRunning { - continue - } - if err := environment.startContainer(ctx, fixture.Name); err != nil { - return environment, joinSetupCleanupError(fmt.Errorf("start Docker fixture %s: %w", fixture.Name, err), environment.Close()) - } - } - // Probes run concurrently once every fixture has started, so waiting - // after all starts costs one probe interval rather than one per fixture. - for _, fixture := range item.Docker.Containers { - if fixture.Health == "" || fixture.State != mission.DockerStateRunning { - continue - } - expected := fixture.Health - settled := func(inspection containerInspection) bool { - return healthStatus(inspection) == expected - } - if err := environment.waitForFixture(ctx, fixture.Name, settled); err != nil { - return environment, joinSetupCleanupError(fmt.Errorf("wait for Docker fixture %s health: %w", fixture.Name, err), environment.Close()) - } + if err := environment.setUp(ctx, *item.Docker); err != nil { + return environment, joinSetupCleanupError(err, environment.Close()) } return environment, nil } -// Availability checks only the Docker resources needed by item and never -// pulls an image or otherwise mutates the daemon. -func (f *Factory) Availability(ctx context.Context, item mission.Mission) game.Availability { - if ctx == nil { - ctx = context.Background() - } - if item.EffectiveEnvironment() != mission.EnvironmentDocker { - return game.EnvironmentAvailability(ctx, f.fallback, item) - } - if item.Docker == nil { - return game.Availability{Available: false, Detail: fmt.Sprintf("Docker mission %s has no Docker setup", item.ID)} - } - if err := mission.ValidateDockerSetup(*item.Docker); err != nil { - return game.Availability{Available: false, Detail: fmt.Sprintf("Docker mission %s is invalid: %v", item.ID, err)} - } - if f.lookupErr != nil || f.runner == nil { - return game.Availability{ - Available: false, - Detail: "Docker labs unavailable: docker executable not found in PATH; install Docker Engine, Docker Desktop, or OrbStack, then run 'opsquest doctor'. Linux missions remain available.", - } - } - if err := ctx.Err(); err != nil { - return game.Availability{Available: false, Detail: fmt.Sprintf("Docker availability check canceled: %v", err)} - } - runtime := f.detectDockerRuntime(ctx) - operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) - result, err := f.runner.run(operationCtx, "version", "--format", "{{.Server.Version}}") - cancel() - if err != nil || strings.TrimSpace(result.stdout) == "" { - detail := dockerFailureDetail(result, err) - instruction := "start Docker or OrbStack and check the active Docker context" - if runtime == dockerRuntimeOrbStack { - instruction = "start OrbStack and check the 'orbstack' Docker context" - } - return game.Availability{ - Available: false, - Detail: "Docker labs unavailable: the Docker-compatible engine could not be reached" + detail + "; " + instruction + ", then run 'opsquest doctor'. Linux missions remain available.", - } - } - seen := make(map[string]bool, len(item.Docker.Images)) - for _, image := range item.Docker.Images { - if seen[image.Reference] { - continue - } - seen[image.Reference] = true - operationCtx, cancel = context.WithTimeout(ctx, dockerOperationTimeout) - result, err = f.runner.run(operationCtx, "image", "inspect", "--format", "{{.Id}}", image.Reference) - cancel() - if err != nil || strings.TrimSpace(result.stdout) == "" { - pullCommand := "docker pull " + image.Reference - if runtime == dockerRuntimeOrbStack { - pullCommand = "DOCKER_CONTEXT=orbstack " + pullCommand - } - return game.Availability{ - Available: false, - Detail: fmt.Sprintf( - "Docker lab image %s is not available locally; run '%s' and try again. OpsQuest never pulls images automatically.", - image.Reference, - pullCommand, - ), - } - } - } - if runtime == dockerRuntimeOrbStack { - return game.Availability{Available: true, Detail: "OrbStack is ready for this mission through the 'orbstack' Docker context."} - } - return game.Availability{Available: true, Detail: "Docker is ready for this mission."} -} - -type dockerRuntime uint8 - -const ( - dockerRuntimeDefault dockerRuntime = iota - dockerRuntimeOrbStack -) - -// detectDockerRuntime identifies known Docker-compatible providers only when -// the Docker CLI reports their official context name. Detection is advisory: -// an older CLI or a broken local context store must not make an otherwise -// reachable Docker engine unavailable. -func (f *Factory) detectDockerRuntime(ctx context.Context) dockerRuntime { - operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) - defer cancel() - result, err := f.runner.run(operationCtx, "context", "show") - if err == nil && strings.TrimSpace(result.stdout) == orbStackContext { - return dockerRuntimeOrbStack - } - return dockerRuntimeDefault -} - func randomSessionID() (string, error) { value := make([]byte, 12) if _, err := rand.Read(value); err != nil { @@ -304,17 +135,3 @@ func joinSetupCleanupError(primary, cleanup error) error { } return fmt.Errorf("%w; cleanup Docker setup: %v", primary, cleanup) } - -func dockerFailureDetail(result runResult, err error) string { - message := strings.TrimSpace(result.stderr) - if message == "" && err != nil { - message = err.Error() - } - if message == "" { - return "" - } - if len(message) > 240 { - message = message[:240] + "…" - } - return ": " + message -} diff --git a/internal/dockerlab/factory_test.go b/internal/dockerlab/factory_test.go new file mode 100644 index 0000000..9ff9b59 --- /dev/null +++ b/internal/dockerlab/factory_test.go @@ -0,0 +1,157 @@ +package dockerlab + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func TestAvailabilityDistinguishesOptionalDockerFailures(t *testing.T) { + item := testDockerMission() + t.Run("CLI missing", func(t *testing.T) { + factory := newFactory(game.SandboxFactory{}, nil, execNotFoundError{}, nil) + availability := factory.Availability(context.Background(), item) + if availability.Available || !strings.Contains(availability.Detail, "executable not found") || !strings.Contains(availability.Detail, "OrbStack") || !strings.Contains(availability.Detail, "Linux missions remain available") { + t.Fatalf("availability = %#v", availability) + } + }) + + t.Run("daemon unavailable", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.daemonErr = errors.New("Cannot connect to the Docker daemon") + availability := testFactory(commandRunner).Availability(context.Background(), item) + if availability.Available || !strings.Contains(availability.Detail, "Docker-compatible engine could not be reached") || !strings.Contains(availability.Detail, "start Docker or OrbStack") { + t.Fatalf("availability = %#v", availability) + } + }) + + t.Run("OrbStack unavailable", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.contextName = "orbstack" + commandRunner.daemonErr = errors.New("Cannot connect to the Docker daemon") + availability := testFactory(commandRunner).Availability(context.Background(), item) + if availability.Available || !strings.Contains(availability.Detail, "start OrbStack") || !strings.Contains(availability.Detail, "'orbstack' Docker context") { + t.Fatalf("availability = %#v", availability) + } + }) + + t.Run("image missing", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.imageAvailable = false + availability := testFactory(commandRunner).Availability(context.Background(), item) + if availability.Available || !strings.Contains(availability.Detail, "docker pull "+testImageReference) || !strings.Contains(availability.Detail, "never pulls") { + t.Fatalf("availability = %#v", availability) + } + for _, call := range commandRunner.snapshotCalls() { + if len(call) > 0 && call[0] == "pull" { + t.Fatalf("Availability invoked an image pull: %q", call) + } + } + }) + + t.Run("OrbStack image missing", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.contextName = "orbstack" + commandRunner.imageAvailable = false + availability := testFactory(commandRunner).Availability(context.Background(), item) + if availability.Available || !strings.Contains(availability.Detail, "DOCKER_CONTEXT=orbstack docker pull "+testImageReference) { + t.Fatalf("availability = %#v", availability) + } + }) + + t.Run("ready", func(t *testing.T) { + availability := testFactory(newFakeDockerRunner()).Availability(context.Background(), item) + if !availability.Available || availability.Detail != "Docker is ready for this mission." { + t.Fatalf("availability = %#v", availability) + } + }) + + t.Run("OrbStack ready", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.contextName = "orbstack" + availability := testFactory(commandRunner).Availability(context.Background(), item) + if !availability.Available || !strings.Contains(availability.Detail, "OrbStack is ready") || !strings.Contains(availability.Detail, "'orbstack' Docker context") { + t.Fatalf("availability = %#v", availability) + } + }) + + t.Run("context detection failure is advisory", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.contextErr = errors.New("context metadata unavailable") + availability := testFactory(commandRunner).Availability(context.Background(), item) + if !availability.Available || availability.Detail != "Docker is ready for this mission." { + t.Fatalf("availability = %#v", availability) + } + }) +} + +type fallbackEnvironment struct{} + +func (fallbackEnvironment) PromptLabel() string { return "/fallback" } + +func (fallbackEnvironment) Execute(context.Context, string) (game.Execution, error) { + return game.Execution{}, nil +} + +func (fallbackEnvironment) Observe(context.Context, mission.Condition) (bool, error) { + return false, nil +} + +func (fallbackEnvironment) CompletionSource() game.CompletionSource { return fallbackCompletion{} } + +func (fallbackEnvironment) Close() error { return nil } + +type fallbackCompletion struct{} + +func (fallbackCompletion) CommandNames() []string { return nil } + +func (fallbackCompletion) PathCandidates(string) []game.CompletionCandidate { + return nil +} + +func TestFactoryDelegatesSimulatedMissionsWithoutDocker(t *testing.T) { + called := false + fallback := game.FactoryFunc(func(_ context.Context, item mission.Mission) (game.Environment, error) { + called = true + return fallbackEnvironment{}, nil + }) + factory := newFactory(fallback, nil, execNotFoundError{}, nil) + created, err := factory.Create(context.Background(), mission.Mission{ID: "linux", Environment: mission.EnvironmentSimulated}) + if err != nil { + t.Fatal(err) + } + if !called || created.PromptLabel() != "/fallback" { + t.Fatalf("fallback called = %v, environment = %#v", called, created) + } +} + +func TestInvalidDockerSetupNeverReachesRunner(t *testing.T) { + commandRunner := newFakeDockerRunner() + item := testDockerMission() + item.Docker.Images[0].Reference = "--privileged" + availability := testFactory(commandRunner).Availability(context.Background(), item) + if availability.Available || !strings.Contains(availability.Detail, "pinned by sha256 digest") { + t.Fatalf("availability = %#v", availability) + } + if len(commandRunner.snapshotCalls()) != 0 { + t.Fatalf("invalid setup reached Docker runner: %q", commandRunner.snapshotCalls()) + } +} + +func TestRuntimeUsesCatalogDockerNameRules(t *testing.T) { + item := testDockerMission() + item.Docker.Images[0].Alias = "fixture.v1" + item.Docker.Containers[0].Image = "fixture.v1" + item.Docker.Containers[1].Image = "fixture.v1" + item.Docker.Containers[0].Name = "api_worker" + if err := mission.ValidateDockerSetup(*item.Docker); err != nil { + t.Fatalf("catalog-valid Docker names were rejected at runtime: %v", err) + } + if action, err := parseAction("docker start api_worker"); err != nil || action.alias != "api_worker" { + t.Fatalf("parse catalog-valid alias = %#v, %v", action, err) + } +} diff --git a/internal/dockerlab/fake_containers_test.go b/internal/dockerlab/fake_containers_test.go new file mode 100644 index 0000000..dfaf458 --- /dev/null +++ b/internal/dockerlab/fake_containers_test.go @@ -0,0 +1,304 @@ +package dockerlab + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +type fakeContainer struct { + labels map[string]string + name string + created time.Time + running bool + restarting bool + status string + log string + exitCode int + oneShot bool + restartPolicy string + restartCount int + healthCmd string + // endpoints maps an attached network's engine name to its ID. + endpoints map[string]string +} + +// runContainer fakes the container subcommands the adapter issues. +func (r *fakeDockerRunner) runContainer(args []string) (runResult, error) { + switch { + case hasPrefix(args, "container", "create"): + r.createAttempts++ + if r.failCreateAt == r.createAttempts { + return runResult{stderr: "fixture creation failed"}, errors.New("exit status 1") + } + // Created IDs use a prefix no seeded test container shares. + id := fmt.Sprintf("c0ffee%058x", r.createAttempts) + labels := make(map[string]string) + name := "" + restartPolicy := "" + healthCmd := "" + endpoints := make(map[string]string) + for index := 0; index+1 < len(args); index++ { + switch args[index] { + case "--network": + if args[index+1] == "none" { + endpoints["none"] = "none-network-id" + } else if network, exists := r.networks[args[index+1]]; exists { + endpoints[network.name] = args[index+1] + } else { + return runResult{stderr: "Error response from daemon: network " + args[index+1] + " not found"}, errors.New("exit status 1") + } + case "--name": + name = args[index+1] + case "--label": + labelName, value, found := strings.Cut(args[index+1], "=") + if found { + labels[labelName] = value + } + case "--restart": + restartPolicy = args[index+1] + case "--health-cmd": + healthCmd = args[index+1] + } + } + container := &fakeContainer{labels: labels, name: name, created: testNow, status: "created", restartPolicy: restartPolicy, healthCmd: healthCmd, endpoints: endpoints} + if len(args) >= 3 && args[len(args)-3] == "opsquest-service" { + container.log = args[len(args)-2] + "\n" + } + if len(args) >= 3 && args[len(args)-3] == "opsquest-diagnostic" { + container.oneShot = true + container.log = args[len(args)-2] + "\n" + exitCode, err := strconv.Atoi(args[len(args)-1]) + if err != nil { + return runResult{}, err + } + container.exitCode = exitCode + } + r.containers[id] = container + r.containerOrder = append(r.containerOrder, id) + if r.failAfterCreateAt == r.createAttempts { + return runResult{stderr: "connection lost after create"}, errors.New("exit status 1") + } + return runResult{stdout: id + "\n"}, nil + case hasPrefix(args, "container", "start") || hasPrefix(args, "container", "restart"): + if r.failNextStart { + r.failNextStart = false + return runResult{stderr: "start failed"}, errors.New("exit status 1") + } + id := args[len(args)-1] + container, exists := r.containers[id] + if !exists { + return missingContainerResult(id) + } + if container.oneShot && strings.HasPrefix(container.restartPolicy, "on-failure") { + // Model a crash loop observed between restarts: Docker reports a + // restarting container as running and appends each attempt's log. + container.running = true + container.restarting = true + container.status = "restarting" + container.restartCount += crashLoopReadyRestarts + container.log = strings.Repeat(strings.SplitAfter(container.log, "\n")[0], container.restartCount+1) + } else if container.oneShot { + container.running = false + container.status = "exited" + } else { + container.running = true + container.status = "running" + container.exitCode = 0 + } + return runResult{stdout: id + "\n"}, nil + case hasPrefix(args, "container", "stop"): + id := args[len(args)-1] + container, exists := r.containers[id] + if !exists { + return missingContainerResult(id) + } + container.running = false + container.restarting = false + container.status = "exited" + return runResult{stdout: id + "\n"}, nil + case hasPrefix(args, "container", "wait"): + id := args[len(args)-1] + container, exists := r.containers[id] + if !exists { + return missingContainerResult(id) + } + return runResult{stdout: strconv.Itoa(container.exitCode) + "\n"}, nil + case hasPrefix(args, "container", "logs"): + id := args[len(args)-1] + container, exists := r.containers[id] + if !exists { + return missingContainerResult(id) + } + if len(args) == 5 && args[2] == "--tail" { + tail, err := strconv.Atoi(args[3]) + if err != nil { + return runResult{}, err + } + lines := strings.SplitAfter(container.log, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if tail < len(lines) { + lines = lines[len(lines)-tail:] + } + return runResult{stdout: strings.Join(lines, "")}, nil + } + return runResult{stdout: container.log}, nil + case hasPrefix(args, "container", "inspect", "--format"): + reference := args[len(args)-1] + if err := r.failNextInspect[reference]; err != nil { + delete(r.failNextInspect, reference) + return runResult{stderr: err.Error()}, err + } + if r.hideNextInspect[reference] { + delete(r.hideNextInspect, reference) + return missingContainerResult(reference) + } + id := r.resolveContainer(reference) + container, exists := r.containers[id] + if !exists { + return missingContainerResult(id) + } + inspection := containerInspection{ID: id, Name: "/" + container.name, Created: container.created.Format(time.RFC3339Nano), RestartCount: container.restartCount} + inspection.Config.Labels = cloneStrings(container.labels) + inspection.State.Running = container.running + inspection.State.Restarting = container.restarting + inspection.State.Status = container.status + inspection.State.ExitCode = container.exitCode + if policy, retries, found := strings.Cut(container.restartPolicy, ":"); found { + inspection.HostConfig.RestartPolicy.Name = policy + inspection.HostConfig.RestartPolicy.MaximumRetryCount, _ = strconv.Atoi(retries) + } else { + inspection.HostConfig.RestartPolicy.Name = container.restartPolicy + } + if len(container.endpoints) > 0 { + inspection.NetworkSettings.Networks = make(map[string]endpointState) + for name, networkID := range container.endpoints { + // Docker can leave NetworkID empty before a first start. + if container.status == "created" { + networkID = "" + } + inspection.NetworkSettings.Networks[name] = endpointState{NetworkID: networkID} + } + } + if container.healthCmd != "" && container.running && !r.stripHealth { + health := "unhealthy" + if container.healthCmd == "true" { + health = "healthy" + } + inspection.State.Health = &healthState{Status: health} + } + encoded, err := json.Marshal(inspection) + if err != nil { + return runResult{}, err + } + return runResult{stdout: string(encoded) + "\n"}, nil + case hasPrefix(args, "container", "ls", "--all", "--quiet", "--no-trunc") && r.failManagedList && args[len(args)-1] == "label="+managedLabel+"=true": + return runResult{stderr: "list failed"}, errors.New("exit status 1") + case hasPrefix(args, "container", "ls", "--all", "--quiet", "--no-trunc"): + filters := make(map[string]string) + for index := 0; index+1 < len(args); index++ { + if args[index] != "--filter" { + continue + } + filter := strings.TrimPrefix(args[index+1], "label=") + name, value, found := strings.Cut(filter, "=") + if found { + filters[name] = value + } + } + var output strings.Builder + for _, id := range r.containerOrder { + container, exists := r.containers[id] + if !exists || !labelsMatch(container.labels, filters) { + continue + } + output.WriteString(id) + output.WriteByte('\n') + } + return runResult{stdout: output.String()}, nil + case len(args) == 3 && hasPrefix(args, "container", "rm"): + id := args[len(args)-1] + container, exists := r.containers[id] + if !exists { + return missingContainerResult(id) + } + if container.running { + return runResult{stderr: "Error response from daemon: cannot remove container: container is running: stop the container before removing or force remove"}, errors.New("exit status 1") + } + delete(r.containers, id) + return runResult{stdout: id + "\n"}, nil + case hasPrefix(args, "container", "rm", "--force"): + id := args[len(args)-1] + if err := r.failNextRemove[id]; err != nil { + delete(r.failNextRemove, id) + return runResult{stderr: err.Error()}, err + } + if _, exists := r.containers[id]; !exists { + return missingContainerResult(id) + } + delete(r.containers, id) + return runResult{stdout: id + "\n"}, nil + default: + return runResult{stderr: "unexpected fake Docker invocation"}, fmt.Errorf("unexpected Docker args: %q", args) + + } +} + +func (r *fakeDockerRunner) resolveContainer(reference string) string { + if _, exists := r.containers[reference]; exists { + return reference + } + for id, container := range r.containers { + if container.name == reference { + return id + } + } + return reference +} + +func (r *fakeDockerRunner) seedContainer(id string, container *fakeContainer) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.containers[id] = container + r.containerOrder = append(r.containerOrder, id) +} + +func (r *fakeDockerRunner) hasContainer(id string) bool { + r.mutex.Lock() + defer r.mutex.Unlock() + _, exists := r.containers[id] + return exists +} + +func (r *fakeDockerRunner) containerCount() int { + r.mutex.Lock() + defer r.mutex.Unlock() + return len(r.containers) +} + +func (r *fakeDockerRunner) corruptLabel(id, name, value string) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.containers[id].labels[name] = value +} + +func (r *fakeDockerRunner) failRemoveOnce(id string, err error) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.failNextRemove[id] = err +} + +func (r *fakeDockerRunner) removeContainer(id string) { + r.mutex.Lock() + defer r.mutex.Unlock() + delete(r.containers, id) +} + +func missingContainerResult(id string) (runResult, error) { + return runResult{stderr: "Error: No such container: " + id}, errors.New("exit status 1") +} diff --git a/internal/dockerlab/fake_networks_test.go b/internal/dockerlab/fake_networks_test.go new file mode 100644 index 0000000..b820c12 --- /dev/null +++ b/internal/dockerlab/fake_networks_test.go @@ -0,0 +1,145 @@ +package dockerlab + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +type fakeNetwork struct { + labels map[string]string + name string + created time.Time + internal bool +} + +// runNetwork fakes the network subcommands the adapter issues. +func (r *fakeDockerRunner) runNetwork(args []string) (runResult, error) { + switch { + case hasPrefix(args, "network", "create"): + r.networkCreates++ + if r.failNetworkCreate { + return runResult{stderr: "network creation failed"}, errors.New("exit status 1") + } + network := &fakeNetwork{labels: make(map[string]string), name: args[len(args)-1], created: testNow} + for index := 2; index < len(args)-1; index++ { + switch args[index] { + case "--internal": + network.internal = true + case "--label": + labelName, value, _ := strings.Cut(args[index+1], "=") + network.labels[labelName] = value + index++ + case "--driver": + index++ + } + } + id := fmt.Sprintf("feed%060x", r.networkCreates) + r.networks[id] = network + r.networkOrder = append(r.networkOrder, id) + return runResult{stdout: id + "\n"}, nil + case hasPrefix(args, "network", "inspect", "--format"): + reference := args[len(args)-1] + id := r.resolveNetwork(reference) + network, exists := r.networks[id] + if !exists { + return runResult{stderr: "Error response from daemon: network " + reference + " not found"}, errors.New("exit status 1") + } + encoded, err := json.Marshal(networkInspection{ID: id, Name: network.name, Created: network.created.Format(time.RFC3339Nano), Driver: "bridge", Internal: network.internal, Labels: cloneStrings(network.labels)}) + if err != nil { + return runResult{}, err + } + return runResult{stdout: string(encoded) + "\n"}, nil + case hasPrefix(args, "network", "ls", "--quiet", "--no-trunc"): + filter := strings.TrimPrefix(args[len(args)-1], "label=") + name, value, _ := strings.Cut(filter, "=") + var output strings.Builder + for _, id := range r.networkOrder { + if network, exists := r.networks[id]; exists && network.labels[name] == value { + output.WriteString(id + "\n") + } + } + return runResult{stdout: output.String()}, nil + case hasPrefix(args, "network", "rm") && r.contextFailureOnNetworkRemove: + return runResult{stderr: contextNotFoundMessage}, errors.New("exit status 1") + case hasPrefix(args, "network", "rm"): + id := args[len(args)-1] + network, exists := r.networks[id] + if !exists { + return runResult{stderr: "Error response from daemon: network " + id + " not found"}, errors.New("exit status 1") + } + for _, container := range r.containers { + if _, attached := container.endpoints[network.name]; attached && container.running { + return runResult{stderr: "Error response from daemon: error while removing network: network " + network.name + " id " + id + " has active endpoints"}, errors.New("exit status 1") + } + } + delete(r.networks, id) + return runResult{stdout: id + "\n"}, nil + case hasPrefix(args, "network", "connect") || hasPrefix(args, "network", "disconnect"): + networkID, containerID := args[len(args)-2], args[len(args)-1] + network, exists := r.networks[networkID] + if !exists { + return runResult{stderr: "Error response from daemon: network " + networkID + " not found"}, errors.New("exit status 1") + } + container, exists := r.containers[containerID] + if !exists { + return missingContainerResult(containerID) + } + _, attached := container.endpoints[network.name] + if args[1] == "disconnect" { + if !attached { + return runResult{stderr: "Error response from daemon: container " + containerID + " is not connected to network " + network.name}, errors.New("exit status 1") + } + delete(container.endpoints, network.name) + return runResult{}, nil + } + if _, disabled := container.endpoints["none"]; disabled { + return runResult{stderr: "Error response from daemon: container cannot be connected to multiple networks with one of the networks in private (none) mode"}, errors.New("exit status 1") + } + if attached { + return runResult{stderr: "Error response from daemon: endpoint with name " + container.name + " already exists in network " + network.name}, errors.New("exit status 1") + } + if container.endpoints == nil { + container.endpoints = make(map[string]string) + } + container.endpoints[network.name] = networkID + return runResult{}, nil + default: + return runResult{stderr: "unexpected fake Docker invocation"}, fmt.Errorf("unexpected Docker args: %q", args) + + } +} + +func (r *fakeDockerRunner) resolveNetwork(reference string) string { + if _, exists := r.networks[reference]; exists { + return reference + } + for id, network := range r.networks { + if network.name == reference { + return id + } + } + return reference +} + +func (r *fakeDockerRunner) seedNetwork(id string, network *fakeNetwork) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.networks[id] = network + r.networkOrder = append(r.networkOrder, id) +} + +func (r *fakeDockerRunner) hasNetwork(id string) bool { + r.mutex.Lock() + defer r.mutex.Unlock() + _, exists := r.networks[id] + return exists +} + +func (r *fakeDockerRunner) networkCount() int { + r.mutex.Lock() + defer r.mutex.Unlock() + return len(r.networks) +} diff --git a/internal/dockerlab/fake_runner_test.go b/internal/dockerlab/fake_runner_test.go new file mode 100644 index 0000000..58f3379 --- /dev/null +++ b/internal/dockerlab/fake_runner_test.go @@ -0,0 +1,138 @@ +package dockerlab + +import ( + "context" + "errors" + "fmt" + "sync" +) + +type fakeDockerRunner struct { + mutex sync.Mutex + + calls [][]string + containers map[string]*fakeContainer + containerOrder []string + contextName string + contextErr error + daemonErr error + imageAvailable bool + failCreateAt int + failAfterCreateAt int + createAttempts int + failNextStart bool + failNextInspect map[string]error + hideNextInspect map[string]bool + failNextRemove map[string]error + failManagedList bool + stripHealth bool + networks map[string]*fakeNetwork + networkOrder []string + networkCreates int + failNetworkCreate bool + // contextFailure makes every invocation fail the way the Docker CLI does + // when the selected context cannot be resolved. + contextFailure bool + // contextFailureOnNetworkRemove fails only network removal that way. + contextFailureOnNetworkRemove bool +} + +// contextNotFoundMessage is the Docker CLI's output when the selected +// context is missing, captured from Docker 29.7.2. +const contextNotFoundMessage = `Failed to initialize: unable to resolve docker endpoint: context "opsquest-missing-context": context not found: open /home/player/.docker/contexts/meta/fdff/meta.json: no such file or directory` + +func (r *fakeDockerRunner) setContextFailure(failing bool) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.contextFailure = failing +} + +func newFakeDockerRunner() *fakeDockerRunner { + return &fakeDockerRunner{ + containers: make(map[string]*fakeContainer), + networks: make(map[string]*fakeNetwork), + contextName: "default", + imageAvailable: true, + failNextInspect: make(map[string]error), + hideNextInspect: make(map[string]bool), + failNextRemove: make(map[string]error), + } +} + +func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if err := ctx.Err(); err != nil { + return runResult{}, err + } + r.calls = append(r.calls, append([]string(nil), args...)) + if r.contextFailure { + return runResult{stderr: contextNotFoundMessage}, errors.New("exit status 1") + } + + switch args[0] { + case "container": + return r.runContainer(args) + case "network": + return r.runNetwork(args) + default: + return r.runEngine(args) + } +} + +// runEngine fakes engine-level queries: context, version, and image checks. +func (r *fakeDockerRunner) runEngine(args []string) (runResult, error) { + switch { + case hasPrefix(args, "context", "show"): + if r.contextErr != nil { + return runResult{stderr: r.contextErr.Error()}, r.contextErr + } + return runResult{stdout: r.contextName + "\n"}, nil + case hasPrefix(args, "version", "--format"): + if r.daemonErr != nil { + return runResult{stderr: r.daemonErr.Error()}, r.daemonErr + } + return runResult{stdout: "27.0.0\n"}, nil + case hasPrefix(args, "image", "inspect", "--format"): + if !r.imageAvailable { + return runResult{stderr: "Error: No such image"}, errors.New("exit status 1") + } + return runResult{stdout: "sha256:test-image\n"}, nil + default: + return runResult{stderr: "unexpected fake Docker invocation"}, fmt.Errorf("unexpected Docker args: %q", args) + + } +} + +func labelsMatch(labels, filters map[string]string) bool { + for name, value := range filters { + if labels[name] != value { + return false + } + } + return true +} + +func (r *fakeDockerRunner) resetCalls() { + r.mutex.Lock() + defer r.mutex.Unlock() + r.calls = nil +} + +func (r *fakeDockerRunner) snapshotCalls() [][]string { + r.mutex.Lock() + defer r.mutex.Unlock() + result := make([][]string, len(r.calls)) + for index := range r.calls { + result[index] = append([]string(nil), r.calls[index]...) + } + return result +} + +func cloneStrings(source map[string]string) map[string]string { + clone := make(map[string]string, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} diff --git a/internal/dockerlab/fixtures.go b/internal/dockerlab/fixtures.go new file mode 100644 index 0000000..abe6fe2 --- /dev/null +++ b/internal/dockerlab/fixtures.go @@ -0,0 +1,301 @@ +package dockerlab + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +// setUp creates the mission's networks and fixtures and brings every fixture +// to its teaching state. On error the caller closes the partial attempt. +func (e *environment) setUp(ctx context.Context, setup mission.DockerSetup) error { + for _, network := range setup.Networks { + if _, err := e.createNetwork(ctx, network.Name, false); err != nil { + return err + } + } + images := make(map[string]string, len(setup.Images)) + for _, image := range setup.Images { + images[image.Alias] = image.Reference + } + for index, fixture := range setup.Containers { + tracked, err := e.createContainer(ctx, index, fixture, images[fixture.Image]) + if tracked != nil { + e.containers = append(e.containers, tracked) + e.byAlias[tracked.alias] = tracked + } + if err != nil { + return err + } + } + for _, fixture := range setup.Containers { + if err := e.startFixture(ctx, fixture); err != nil { + return err + } + } + // Probes run concurrently once every fixture has started, so waiting + // after all starts costs one probe interval rather than one per fixture. + for _, fixture := range setup.Containers { + if fixture.Health == "" || fixture.State != mission.DockerStateRunning { + continue + } + expected := fixture.Health + settled := func(inspection containerInspection) bool { + return healthStatus(inspection) == expected + } + if err := e.waitForFixture(ctx, fixture.Name, settled); err != nil { + return fmt.Errorf("wait for Docker fixture %s health: %w", fixture.Name, err) + } + } + return nil +} + +// startFixture brings one created fixture to its initial state: a visible +// crash loop, a completed diagnostic job, or a running service. +func (e *environment) startFixture(ctx context.Context, fixture mission.DockerContainerSpec) error { + switch { + case fixture.Restart == mission.DockerRestartOnFailure: + if err := e.startContainer(ctx, fixture.Name); err != nil { + return fmt.Errorf("start crash-loop Docker fixture %s: %w", fixture.Name, err) + } + looping := func(inspection containerInspection) bool { + return inspection.RestartCount >= crashLoopReadyRestarts + } + if err := e.waitForFixture(ctx, fixture.Name, looping); err != nil { + return fmt.Errorf("wait for crash-loop Docker fixture %s: %w", fixture.Name, err) + } + case fixture.ExitCode != nil: + if err := e.startContainer(ctx, fixture.Name); err != nil { + return fmt.Errorf("start diagnostic Docker fixture %s: %w", fixture.Name, err) + } + if err := e.waitContainer(ctx, fixture.Name, *fixture.ExitCode); err != nil { + return fmt.Errorf("wait for diagnostic Docker fixture %s: %w", fixture.Name, err) + } + case fixture.State == mission.DockerStateRunning: + if err := e.startContainer(ctx, fixture.Name); err != nil { + return fmt.Errorf("start Docker fixture %s: %w", fixture.Name, err) + } + } + return nil +} + +func (e *environment) createContainer(ctx context.Context, index int, fixture mission.DockerContainerSpec, reference string) (*trackedContainer, error) { + actualName := fmt.Sprintf("opsquest-%s-c%02d", e.sessionID, index+1) + tracked := &trackedContainer{ + logicalID: fmt.Sprintf("lab-%02d", index+1), + alias: fixture.Name, + imageAlias: fixture.Image, + actualName: actualName, + } + joined, err := e.fixtureNetworks(fixture) + if err != nil { + return nil, err + } + args := []string{ + "container", "create", + "--pull", "never", + "--name", actualName, + } + args = append(args, e.ownershipLabels(fixture.Name)...) + args = append(args, networkArgs(fixture.Name, joined)...) + args = append(args, hardeningArgs(fixture)...) + args = append(args, healthArgs(fixture)...) + args = append(args, fixtureCommand(fixture, reference)...) + result, err := e.run(ctx, args...) + if err != nil { + createErr := fmt.Errorf("create Docker fixture %s: %w", fixture.Name, err) + return e.reconcileAmbiguousCreate(tracked, createErr) + } + id := strings.TrimSpace(result.stdout) + if !containerIDPattern.MatchString(id) { + createErr := fmt.Errorf("create Docker fixture %s: Docker returned an invalid container ID", fixture.Name) + return e.reconcileAmbiguousCreate(tracked, createErr) + } + tracked.id = id + for _, network := range joined[min(1, len(joined)):] { + if _, err := e.run(ctx, "network", "connect", "--alias", fixture.Name, network.id, id); err != nil { + return tracked, fmt.Errorf("connect Docker fixture %s to network %s: %w", fixture.Name, network.name, err) + } + } + return tracked, nil +} + +// reconcileAmbiguousCreate handles the case where the Docker CLI reports an +// error or malformed output after the daemon may already have created the +// container. The generated name is internal, and ownership labels must match +// before the recovered exact ID is admitted to the cleanup set. +func (e *environment) reconcileAmbiguousCreate(tracked *trackedContainer, createErr error) (*trackedContainer, error) { + ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + inspection, exists, err := e.inspectReferenceUnchecked(ctx, tracked.actualName) + if err != nil { + // The create may have reached the daemon even though reconciliation is + // temporarily unavailable. Retain the generated name so cleanup can + // safely re-inspect ownership before removing anything. + return tracked, errors.Join(createErr, fmt.Errorf("reconcile Docker fixture %s: %w", tracked.alias, err)) + } + if !exists { + // A successful create can become visible just after a transient missing + // result. Keep the generated name in the cleanup set so Close performs + // one more ownership-checked inspection before considering it absent. + return tracked, createErr + } + labels := inspection.Config.Labels + if !e.owns(labels, tracked.alias) { + return nil, errors.Join(createErr, fmt.Errorf("refusing to recover Docker fixture %s because its ownership labels do not match", tracked.alias)) + } + if !containerIDPattern.MatchString(inspection.ID) { + return nil, errors.Join(createErr, fmt.Errorf("reconcile Docker fixture %s: Docker returned an invalid container ID", tracked.alias)) + } + tracked.id = inspection.ID + return tracked, createErr +} + +// fixtureNetworks resolves the declared networks a fixture joins, in order. +func (e *environment) fixtureNetworks(fixture mission.DockerContainerSpec) ([]*trackedNetwork, error) { + var joined []*trackedNetwork + for _, name := range fixture.Networks { + network, exists := e.network(name) + if !exists || network.id == "" { + return nil, fmt.Errorf("create Docker fixture %s: network %q was not created", fixture.Name, name) + } + joined = append(joined, network) + } + return joined, nil +} + +// networkArgs keeps networking disabled for a fixture without declared +// networks. Otherwise the fixture starts on its first attempt network and +// joins the rest after creation, before it starts. +func networkArgs(alias string, joined []*trackedNetwork) []string { + if len(joined) == 0 { + return []string{"--network", "none"} + } + return []string{"--network", joined[0].id, "--network-alias", alias} +} + +// hardeningArgs are the fixed runtime restrictions every fixture receives. +// Only a declared crash-loop fixture gets a restart policy other than no. +func hardeningArgs(fixture mission.DockerContainerSpec) []string { + restartPolicy := "no" + if fixture.Restart == mission.DockerRestartOnFailure { + restartPolicy = fmt.Sprintf("on-failure:%d", crashLoopMaxRetries) + } + return []string{ + "--read-only", + "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=8m", + "--user", "65532:65532", + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + "--pids-limit", "64", + "--memory", "128m", + "--memory-swap", "128m", + "--cpus", "0.5", + "--ulimit", "nofile=256:256", + "--restart", restartPolicy, + "--stop-timeout", "1", + } +} + +// healthArgs configures a fixed probe for a fixture that declares health. +// The probe is a BusyBox applet selected by a validated enum; mission content +// and player input never supply the probe command. +func healthArgs(fixture mission.DockerContainerSpec) []string { + probe, configured := healthProbes[fixture.Health] + if !configured { + return nil + } + return []string{ + "--health-cmd", probe, + "--health-interval", "1s", + "--health-timeout", "1s", + "--health-retries", "1", + } +} + +// fixtureCommand selects the fixed program a fixture runs. Log text, exit +// status, and lifetime are data arguments, never interpolated into the shell +// program. +func fixtureCommand(fixture mission.DockerContainerSpec, reference string) []string { + lifetime := strconv.Itoa(int(fixtureLifetime / time.Second)) + switch { + case fixture.ExitCode != nil: + return []string{ + "--entrypoint", "/bin/sh", + reference, + "-c", `printf '%s\n' "$1"; exit "$2"`, + "opsquest-diagnostic", fixture.Log, strconv.Itoa(*fixture.ExitCode), + } + case fixture.Log != "": + return []string{ + "--entrypoint", "/bin/sh", + reference, + "-c", `printf '%s\n' "$1"; exec sleep "$2"`, + "opsquest-service", fixture.Log, lifetime, + } + default: + return []string{"--entrypoint", "/bin/sleep", reference, lifetime} + } +} + +// healthProbes maps a fixture health enum to a fixed probe command. +var healthProbes = map[string]string{ + mission.DockerHealthHealthy: "true", + mission.DockerHealthUnhealthy: "false", +} + +func (e *environment) waitContainer(ctx context.Context, alias string, expectedExitCode int) error { + result, err := e.runOnAlias(ctx, alias, "container", "wait") + if err != nil { + return err + } + exitCode, err := strconv.Atoi(strings.TrimSpace(result.stdout)) + if err != nil || exitCode != expectedExitCode { + return fmt.Errorf("Docker fixture %s exited with unexpected status %q", alias, strings.TrimSpace(result.stdout)) + } + return nil +} + +// waitForFixture polls sanitized inspection until ready reports true. Setup +// uses it for fixtures whose teaching state (a health verdict or a visible +// crash loop) appears shortly after start. +func (e *environment) waitForFixture(ctx context.Context, alias string, ready func(containerInspection) bool) error { + tracked, exists := e.container(alias) + if !exists { + return fmt.Errorf("docker: container %q is not part of this mission", alias) + } + interval := e.pollInterval + if interval <= 0 { + interval = fixtureReadyInterval + } + timeout := e.readyTimeout + if timeout <= 0 { + timeout = fixtureReadyTimeout + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + for { + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("Docker fixture %s disappeared during setup", alias) + } + if ready(inspection) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return fmt.Errorf("Docker fixture %s did not reach its teaching state within %s", alias, timeout) + case <-time.After(interval): + } + } +} diff --git a/internal/dockerlab/helpers_test.go b/internal/dockerlab/helpers_test.go new file mode 100644 index 0000000..499a5ef --- /dev/null +++ b/internal/dockerlab/helpers_test.go @@ -0,0 +1,122 @@ +package dockerlab + +import ( + "context" + "reflect" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/game" + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +const ( + testImageReference = "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + testOwnerPID = 4242 + testOwnerHost = "test-host" +) + +var testNow = time.Date(2026, time.September, 30, 12, 0, 0, 0, time.UTC) + +func hasPrefix(actual []string, prefix ...string) bool { + return len(actual) >= len(prefix) && reflect.DeepEqual(actual[:len(prefix)], prefix) +} + +func testDockerMission() mission.Mission { + return mission.Mission{ + ID: "docker-container-census", + Number: 17, + Track: mission.TrackDocker, + Environment: mission.EnvironmentDocker, + Docker: &mission.DockerSetup{ + Images: []mission.DockerImageSpec{{Alias: "fixture", Reference: testImageReference}}, + Containers: []mission.DockerContainerSpec{ + {Name: "api", Image: "fixture", State: "stopped"}, + {Name: "metrics", Image: "fixture", State: "running"}, + }, + }, + } +} + +func testDiagnosticDockerMission() mission.Mission { + exitCode := 23 + return mission.Mission{ + ID: "docker-diagnostic", + Number: 21, + Track: mission.TrackDocker, + Environment: mission.EnvironmentDocker, + Docker: &mission.DockerSetup{ + Images: []mission.DockerImageSpec{{Alias: "fixture", Reference: testImageReference}}, + Containers: []mission.DockerContainerSpec{{ + Name: "job", + Image: "fixture", + State: mission.DockerStateStopped, + Log: "ERROR: literal $(touch /host); still data", + ExitCode: &exitCode, + }}, + }, + } +} + +func testFactory(commandRunner runner) *Factory { + factory := newFactory(game.SandboxFactory{}, commandRunner, nil, func() (string, error) { + return strings.Repeat("a", 24), nil + }) + factory.owner = testOwner(func(pid int) bool { return pid == testOwnerPID }) + factory.pollInterval = time.Millisecond + return factory +} + +func testOwner(alive func(int) bool) processOwner { + return processOwner{pid: testOwnerPID, host: testOwnerHost, alive: alive, now: func() time.Time { return testNow }} +} + +// firstCall returns the first recorded Docker invocation with prefix and its +// position, so setup assertions do not depend on unrelated preflight calls. +func firstCall(t *testing.T, calls [][]string, prefix ...string) (int, []string) { + t.Helper() + for index, call := range calls { + if hasPrefix(call, prefix...) { + return index, call + } + } + t.Fatalf("no Docker call with prefix %q in %q", prefix, calls) + return -1, nil +} + +func createTestEnvironment(t *testing.T, commandRunner *fakeDockerRunner) *environment { + t.Helper() + created, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + environment, ok := created.(*environment) + if !ok { + t.Fatalf("Create() type = %T, want *environment", created) + } + t.Cleanup(func() { + _ = environment.Close() + }) + return environment +} + +func createEnvironmentForMission(t *testing.T, commandRunner *fakeDockerRunner, item mission.Mission) *environment { + t.Helper() + created, err := testFactory(commandRunner).Create(context.Background(), item) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + environment, ok := created.(*environment) + if !ok { + t.Fatalf("Create() type = %T, want *environment", created) + } + t.Cleanup(func() { + _ = environment.Close() + }) + return environment +} + +type execNotFoundError struct{} + +func (execNotFoundError) Error() string { return "executable not found" } diff --git a/internal/dockerlab/inspection.go b/internal/dockerlab/inspection.go new file mode 100644 index 0000000..4f3353d --- /dev/null +++ b/internal/dockerlab/inspection.go @@ -0,0 +1,137 @@ +package dockerlab + +import ( + "context" + "encoding/json" + "fmt" +) + +func (e *environment) logicalInspect(ctx context.Context, alias string) (string, error) { + tracked, exists := e.container(alias) + if !exists { + return "", fmt.Errorf("docker: container %q is not part of this mission", alias) + } + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil { + return "", err + } + if !exists { + return "", fmt.Errorf("docker: container %q no longer exists", alias) + } + type logicalHealth struct { + Status string `json:"Status"` + } + logical := struct { + ID string `json:"Id"` + Name string `json:"Name"` + Image string `json:"Image"` + RestartCount int `json:"RestartCount"` + State struct { + Running bool `json:"Running"` + Restarting bool `json:"Restarting"` + Status string `json:"Status"` + ExitCode int `json:"ExitCode"` + Health *logicalHealth `json:"Health,omitempty"` + } `json:"State"` + HostConfig struct { + RestartPolicy restartPolicy `json:"RestartPolicy"` + } `json:"HostConfig"` + NetworkSettings struct { + Networks []string `json:"Networks"` + } `json:"NetworkSettings"` + }{ID: tracked.logicalID, Name: tracked.alias, Image: tracked.imageAlias, RestartCount: inspection.RestartCount} + logical.State.Running = inspection.State.Running + logical.State.Restarting = inspection.State.Restarting + logical.State.Status = inspection.State.Status + logical.State.ExitCode = inspection.State.ExitCode + if health := healthStatus(inspection); health != "none" { + logical.State.Health = &logicalHealth{Status: health} + } + logical.NetworkSettings.Networks = e.logicalNetworks(inspection) + logical.HostConfig.RestartPolicy = inspection.HostConfig.RestartPolicy + if logical.HostConfig.RestartPolicy.Name == "" { + logical.HostConfig.RestartPolicy.Name = "no" + } + encoded, err := json.MarshalIndent(logical, "", " ") + if err != nil { + return "", err + } + return string(encoded) + "\n", nil +} + +type restartPolicy struct { + Name string `json:"Name"` + MaximumRetryCount int `json:"MaximumRetryCount"` +} + +type healthState struct { + Status string `json:"Status"` +} + +type containerInspection struct { + ID string `json:"Id"` + Name string `json:"Name"` + Created string `json:"Created"` + RestartCount int `json:"RestartCount"` + Config struct { + Labels map[string]string `json:"Labels"` + } `json:"Config"` + HostConfig struct { + RestartPolicy restartPolicy `json:"RestartPolicy"` + } `json:"HostConfig"` + State struct { + Running bool `json:"Running"` + Restarting bool `json:"Restarting"` + Status string `json:"Status"` + ExitCode int `json:"ExitCode"` + Health *healthState `json:"Health"` + } `json:"State"` + NetworkSettings struct { + // Networks is keyed by the engine network name. NetworkID can be + // empty before a container first starts, so both identify a network. + Networks map[string]endpointState `json:"Networks"` + } `json:"NetworkSettings"` +} + +type endpointState struct { + NetworkID string `json:"NetworkID"` +} + +func (e *environment) inspect(ctx context.Context, id string) (containerInspection, bool, error) { + if err := e.ready(ctx); err != nil { + return containerInspection{}, false, err + } + return e.inspectUnchecked(ctx, id) +} + +func (e *environment) inspectUnchecked(ctx context.Context, id string) (containerInspection, bool, error) { + inspection, exists, err := e.inspectReferenceUnchecked(ctx, id) + if err != nil || !exists { + return inspection, exists, err + } + if inspection.ID != id { + return containerInspection{}, false, fmt.Errorf("Docker inspection returned an unexpected container ID") + } + return inspection, true, nil +} + +func (e *environment) inspectReferenceUnchecked(ctx context.Context, reference string) (containerInspection, bool, error) { + return inspectReference(ctx, e.runner, reference) +} + +// inspectReference inspects one exact container ID or generated name. It is +// shared by attempt environments and the orphan janitor. +func inspectReference(ctx context.Context, commandRunner runner, reference string) (containerInspection, bool, error) { + result, err := runDocker(ctx, commandRunner, "container", "inspect", "--format", "{{json .}}", reference) + if err != nil { + if isMissingContainer(result) { + return containerInspection{}, false, nil + } + return containerInspection{}, false, err + } + var inspection containerInspection + if err := json.Unmarshal([]byte(result.stdout), &inspection); err != nil { + return containerInspection{}, false, fmt.Errorf("decode Docker inspection: %w", err) + } + return inspection, true, nil +} diff --git a/internal/dockerlab/network.go b/internal/dockerlab/network.go index 9e9cd6b..1b5b0fc 100644 --- a/internal/dockerlab/network.go +++ b/internal/dockerlab/network.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "sort" - "strconv" "strings" ) @@ -69,18 +68,8 @@ func (e *environment) createNetwork(ctx context.Context, name string, playerCrea // Internal networks have no route to the host network or the // internet; only containers of this attempt can join them. "--internal", - "--label", managedLabel + "=true", - "--label", schemaLabel + "=1", - "--label", sessionLabel + "=" + e.sessionID, - "--label", missionLabel + "=" + e.missionID, - "--label", aliasLabel + "=" + name, - } - if e.ownerHost != "" && e.ownerPID > 0 { - args = append(args, - "--label", ownerPIDLabel+"="+strconv.Itoa(e.ownerPID), - "--label", ownerHostLabel+"="+e.ownerHost, - ) } + args = append(args, e.ownershipLabels(name)...) args = append(args, tracked.actualName) result, err := e.run(ctx, args...) id := strings.TrimSpace(result.stdout) @@ -112,7 +101,7 @@ func (e *environment) reconcileAmbiguousNetwork(tracked *trackedNetwork, createE return createErr } labels := inspection.Labels - if labels[managedLabel] != "true" || labels[sessionLabel] != e.sessionID || labels[missionLabel] != e.missionID || labels[aliasLabel] != tracked.name || !containerIDPattern.MatchString(inspection.ID) { + if !e.owns(labels, tracked.name) || !containerIDPattern.MatchString(inspection.ID) { return errors.Join(createErr, fmt.Errorf("refusing to recover Docker network %s because its ownership does not match", tracked.name)) } tracked.id = inspection.ID @@ -157,21 +146,6 @@ func inspectNetworkReference(ctx context.Context, commandRunner runner, referenc return inspection, true, nil } -// isMissingNetwork reports whether Docker said that exactly reference does -// not exist. It accepts only the engine's network-specific wording that names -// the reference, so CLI, context, and transport failures such as -// `context "x": context not found` propagate as errors instead of looking -// like a removed network. -func isMissingNetwork(result runResult, reference string) bool { - if reference == "" { - return false - } - message := strings.ToLower(result.stderr + "\n" + result.stdout) - reference = strings.ToLower(reference) - return strings.Contains(message, "network "+reference+" not found") || - strings.Contains(message, "no such network: "+reference) -} - // attachedTo reports whether a container inspection shows an endpoint on // network. func attachedTo(inspection containerInspection, network *trackedNetwork) bool { @@ -221,276 +195,3 @@ func (e *environment) attachedContainers(ctx context.Context, network *trackedNe } return attached, nil } - -// existingNetwork resolves a logical name to a network that still exists. -func (e *environment) existingNetwork(ctx context.Context, name string) (*trackedNetwork, error) { - network, exists := e.network(name) - if !exists { - return nil, fmt.Errorf("docker: network %q is not part of this mission", name) - } - _, exists, err := e.inspectNetwork(ctx, network) - if err != nil { - return nil, err - } - if !exists { - return nil, fmt.Errorf("docker: network %q has been removed", name) - } - return network, nil -} - -// existingContainer resolves an alias to a container that still exists and -// returns its current inspection. -func (e *environment) existingContainer(ctx context.Context, alias string) (*trackedContainer, containerInspection, error) { - tracked, exists := e.container(alias) - if !exists { - return nil, containerInspection{}, fmt.Errorf("docker: container %q is not part of this mission", alias) - } - inspection, exists, err := e.inspect(ctx, tracked.id) - if err != nil { - return nil, containerInspection{}, err - } - if !exists { - return nil, containerInspection{}, fmt.Errorf("docker: container %q has been removed", alias) - } - return tracked, inspection, nil -} - -func (e *environment) listNetworks(ctx context.Context) (string, error) { - type row struct { - logicalID string - name string - driver string - internal bool - } - var rows []row - nameWidth := len("NAME") - for _, network := range e.snapshotNetworks() { - if network.id == "" { - continue - } - if current, bound := e.network(network.name); !bound || current != network { - continue - } - inspection, exists, err := e.inspectNetwork(ctx, network) - if err != nil { - return "", err - } - if !exists { - continue - } - nameWidth = max(nameWidth, len(network.name)) - rows = append(rows, row{logicalID: network.logicalID, name: network.name, driver: inspection.Driver, internal: inspection.Internal}) - } - var output strings.Builder - fmt.Fprintf(&output, "%-11s %-*s %-7s %s\n", "NETWORK ID", nameWidth, "NAME", "DRIVER", "INTERNAL") - for _, item := range rows { - fmt.Fprintf(&output, "%-11s %-*s %-7s %t\n", item.logicalID, nameWidth, item.name, item.driver, item.internal) - } - return output.String(), nil -} - -func (e *environment) createPlayerNetwork(ctx context.Context, name string) (string, error) { - if existing, bound := e.network(name); bound { - _, exists, err := e.inspectNetwork(ctx, existing) - if err != nil { - return "", err - } - if exists { - return "", fmt.Errorf("docker: network %q already exists", name) - } - } - created := 0 - for _, network := range e.snapshotNetworks() { - if network.playerCreated { - created++ - } - } - if created >= maxPlayerNetworks { - return "", fmt.Errorf("docker: this lab lets you create at most %d networks per attempt", maxPlayerNetworks) - } - network, err := e.createNetwork(ctx, name, true) - if err != nil { - return "", err - } - return network.logicalID + "\n", nil -} - -func (e *environment) removeNetwork(ctx context.Context, name string) error { - network, exists := e.network(name) - if !exists { - return fmt.Errorf("docker: network %q is not part of this mission", name) - } - _, exists, err := e.inspectNetwork(ctx, network) - if err != nil { - return err - } - if !exists { - return fmt.Errorf("docker: network %q has already been removed", name) - } - // Docker would remove a network that only stopped containers use and - // leave them unable to start. The lab refuses instead, so every - // container stays startable. - attached, err := e.attachedContainers(ctx, network) - if err != nil { - return err - } - if len(attached) > 0 { - return fmt.Errorf("docker: network %q still has containers attached (%s); disconnect or remove them first", name, strings.Join(attached, ", ")) - } - return e.runNetworkAction(ctx, network, nil, "network", "rm", network.id) -} - -func (e *environment) logicalNetworkInspect(ctx context.Context, name string) (string, error) { - network, exists := e.network(name) - if !exists { - return "", fmt.Errorf("docker: network %q is not part of this mission", name) - } - inspection, exists, err := e.inspectNetwork(ctx, network) - if err != nil { - return "", err - } - if !exists { - return "", fmt.Errorf("docker: network %q no longer exists", name) - } - attached, err := e.attachedContainers(ctx, network) - if err != nil { - return "", err - } - sort.Strings(attached) - logical := struct { - ID string `json:"Id"` - Name string `json:"Name"` - Driver string `json:"Driver"` - Internal bool `json:"Internal"` - Containers []string `json:"Containers"` - }{ID: network.logicalID, Name: network.name, Driver: inspection.Driver, Internal: inspection.Internal, Containers: append([]string{}, attached...)} - encoded, err := json.MarshalIndent(logical, "", " ") - if err != nil { - return "", err - } - return string(encoded) + "\n", nil -} - -func (e *environment) connectNetwork(ctx context.Context, name, alias string) error { - network, err := e.existingNetwork(ctx, name) - if err != nil { - return err - } - tracked, inspection, err := e.existingContainer(ctx, alias) - if err != nil { - return err - } - if networkingDisabled(inspection) { - return fmt.Errorf("docker: container %q runs with networking disabled in this lab and cannot join a network", alias) - } - if attachedTo(inspection, network) { - return fmt.Errorf("docker: container %q is already connected to network %q", alias, name) - } - return e.runNetworkAction(ctx, network, tracked, "network", "connect", "--alias", alias, network.id, tracked.id) -} - -func (e *environment) disconnectNetwork(ctx context.Context, name, alias string) error { - network, err := e.existingNetwork(ctx, name) - if err != nil { - return err - } - tracked, inspection, err := e.existingContainer(ctx, alias) - if err != nil { - return err - } - if !attachedTo(inspection, network) { - return fmt.Errorf("docker: container %q is not connected to network %q", alias, name) - } - return e.runNetworkAction(ctx, network, tracked, "network", "disconnect", network.id, tracked.id) -} - -// runNetworkAction runs one fixed network invocation and rewrites engine -// errors so exact network and container identities never reach the player. -func (e *environment) runNetworkAction(ctx context.Context, network *trackedNetwork, container *trackedContainer, args ...string) error { - operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) - defer cancel() - result, err := e.runner.run(operationCtx, args...) - if err == nil { - return nil - } - message := dockerFailureDetail(result, err) - message = redactNetworkIdentifiers(message, network) - if container != nil { - message = redactIdentifiers(message, container) - } - return fmt.Errorf("Docker command failed%s", message) -} - -func redactNetworkIdentifiers(message string, network *trackedNetwork) string { - if network.id != "" { - message = strings.ReplaceAll(message, network.id, network.logicalID) - if len(network.id) >= 12 { - message = strings.ReplaceAll(message, network.id[:12], network.logicalID) - } - } - return strings.ReplaceAll(message, network.actualName, network.name) -} - -// shareNetwork reports whether both containers exist and have at least one -// attempt network in common. Both results are false when either is gone. -func (e *environment) shareNetwork(ctx context.Context, first, second string) (shared, bothExist bool, err error) { - var inspections [2]containerInspection - for index, alias := range []string{first, second} { - tracked, exists := e.container(alias) - if !exists { - return false, false, nil - } - inspection, exists, err := e.inspect(ctx, tracked.id) - if err != nil || !exists { - return false, false, err - } - inspections[index] = inspection - } - for _, network := range e.snapshotNetworks() { - if network.id != "" && attachedTo(inspections[0], network) && attachedTo(inspections[1], network) { - return true, true, nil - } - } - return false, true, nil -} - -func (e *environment) networkAbsent(ctx context.Context, name string) (bool, error) { - network, exists := e.network(name) - if !exists { - return false, nil - } - _, exists, err := e.inspectNetwork(ctx, network) - return err == nil && !exists, err -} - -// closeNetworks removes attempt networks after their containers, verifying -// ownership labels on each exact ID. Networks that cannot be resolved yet -// are returned for a later retry. -func (e *environment) closeNetworks(ctx context.Context, networks []*trackedNetwork) ([]*trackedNetwork, []error) { - var unresolved []*trackedNetwork - var cleanupErrors []error - for index := len(networks) - 1; index >= 0; index-- { - network := networks[index] - inspection, exists, err := e.inspectNetworkUnchecked(ctx, network) - if err != nil { - cleanupErrors = append(cleanupErrors, fmt.Errorf("inspect Docker network %s before cleanup: %w", network.name, err)) - unresolved = append(unresolved, network) - continue - } - if !exists { - continue - } - labels := inspection.Labels - if labels[managedLabel] != "true" || labels[schemaLabel] != "1" || labels[sessionLabel] != e.sessionID || labels[missionLabel] != e.missionID || labels[aliasLabel] != network.name || !containerIDPattern.MatchString(inspection.ID) { - cleanupErrors = append(cleanupErrors, fmt.Errorf("refusing to remove Docker network %s because its ownership labels do not match", network.name)) - unresolved = append(unresolved, network) - continue - } - network.id = inspection.ID - if result, err := e.run(ctx, "network", "rm", inspection.ID); err != nil && !isMissingNetwork(result, inspection.ID) { - cleanupErrors = append(cleanupErrors, fmt.Errorf("remove Docker network %s: %w", network.name, err)) - unresolved = append(unresolved, network) - } - } - return unresolved, cleanupErrors -} diff --git a/internal/dockerlab/network_commands.go b/internal/dockerlab/network_commands.go new file mode 100644 index 0000000..0e6397f --- /dev/null +++ b/internal/dockerlab/network_commands.go @@ -0,0 +1,202 @@ +package dockerlab + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// existingNetwork resolves a logical name to a network that still exists. +func (e *environment) existingNetwork(ctx context.Context, name string) (*trackedNetwork, error) { + network, exists := e.network(name) + if !exists { + return nil, fmt.Errorf("docker: network %q is not part of this mission", name) + } + _, exists, err := e.inspectNetwork(ctx, network) + if err != nil { + return nil, err + } + if !exists { + return nil, fmt.Errorf("docker: network %q has been removed", name) + } + return network, nil +} + +// existingContainer resolves an alias to a container that still exists and +// returns its current inspection. +func (e *environment) existingContainer(ctx context.Context, alias string) (*trackedContainer, containerInspection, error) { + tracked, exists := e.container(alias) + if !exists { + return nil, containerInspection{}, fmt.Errorf("docker: container %q is not part of this mission", alias) + } + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil { + return nil, containerInspection{}, err + } + if !exists { + return nil, containerInspection{}, fmt.Errorf("docker: container %q has been removed", alias) + } + return tracked, inspection, nil +} + +func (e *environment) listNetworks(ctx context.Context) (string, error) { + type row struct { + logicalID string + name string + driver string + internal bool + } + var rows []row + nameWidth := len("NAME") + for _, network := range e.snapshotNetworks() { + if network.id == "" { + continue + } + if current, bound := e.network(network.name); !bound || current != network { + continue + } + inspection, exists, err := e.inspectNetwork(ctx, network) + if err != nil { + return "", err + } + if !exists { + continue + } + nameWidth = max(nameWidth, len(network.name)) + rows = append(rows, row{logicalID: network.logicalID, name: network.name, driver: inspection.Driver, internal: inspection.Internal}) + } + var output strings.Builder + fmt.Fprintf(&output, "%-11s %-*s %-7s %s\n", "NETWORK ID", nameWidth, "NAME", "DRIVER", "INTERNAL") + for _, item := range rows { + fmt.Fprintf(&output, "%-11s %-*s %-7s %t\n", item.logicalID, nameWidth, item.name, item.driver, item.internal) + } + return output.String(), nil +} + +func (e *environment) createPlayerNetwork(ctx context.Context, name string) (string, error) { + if existing, bound := e.network(name); bound { + _, exists, err := e.inspectNetwork(ctx, existing) + if err != nil { + return "", err + } + if exists { + return "", fmt.Errorf("docker: network %q already exists", name) + } + } + created := 0 + for _, network := range e.snapshotNetworks() { + if network.playerCreated { + created++ + } + } + if created >= maxPlayerNetworks { + return "", fmt.Errorf("docker: this lab lets you create at most %d networks per attempt", maxPlayerNetworks) + } + network, err := e.createNetwork(ctx, name, true) + if err != nil { + return "", err + } + return network.logicalID + "\n", nil +} + +func (e *environment) removeNetwork(ctx context.Context, name string) error { + network, exists := e.network(name) + if !exists { + return fmt.Errorf("docker: network %q is not part of this mission", name) + } + _, exists, err := e.inspectNetwork(ctx, network) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("docker: network %q has already been removed", name) + } + // Docker would remove a network that only stopped containers use and + // leave them unable to start. The lab refuses instead, so every + // container stays startable. + attached, err := e.attachedContainers(ctx, network) + if err != nil { + return err + } + if len(attached) > 0 { + return fmt.Errorf("docker: network %q still has containers attached (%s); disconnect or remove them first", name, strings.Join(attached, ", ")) + } + return e.runNetworkAction(ctx, network, nil, "network", "rm", network.id) +} + +func (e *environment) logicalNetworkInspect(ctx context.Context, name string) (string, error) { + network, exists := e.network(name) + if !exists { + return "", fmt.Errorf("docker: network %q is not part of this mission", name) + } + inspection, exists, err := e.inspectNetwork(ctx, network) + if err != nil { + return "", err + } + if !exists { + return "", fmt.Errorf("docker: network %q no longer exists", name) + } + attached, err := e.attachedContainers(ctx, network) + if err != nil { + return "", err + } + sort.Strings(attached) + logical := struct { + ID string `json:"Id"` + Name string `json:"Name"` + Driver string `json:"Driver"` + Internal bool `json:"Internal"` + Containers []string `json:"Containers"` + }{ID: network.logicalID, Name: network.name, Driver: inspection.Driver, Internal: inspection.Internal, Containers: append([]string{}, attached...)} + encoded, err := json.MarshalIndent(logical, "", " ") + if err != nil { + return "", err + } + return string(encoded) + "\n", nil +} + +func (e *environment) connectNetwork(ctx context.Context, name, alias string) error { + network, err := e.existingNetwork(ctx, name) + if err != nil { + return err + } + tracked, inspection, err := e.existingContainer(ctx, alias) + if err != nil { + return err + } + if networkingDisabled(inspection) { + return fmt.Errorf("docker: container %q runs with networking disabled in this lab and cannot join a network", alias) + } + if attachedTo(inspection, network) { + return fmt.Errorf("docker: container %q is already connected to network %q", alias, name) + } + return e.runNetworkAction(ctx, network, tracked, "network", "connect", "--alias", alias, network.id, tracked.id) +} + +func (e *environment) disconnectNetwork(ctx context.Context, name, alias string) error { + network, err := e.existingNetwork(ctx, name) + if err != nil { + return err + } + tracked, inspection, err := e.existingContainer(ctx, alias) + if err != nil { + return err + } + if !attachedTo(inspection, network) { + return fmt.Errorf("docker: container %q is not connected to network %q", alias, name) + } + return e.runNetworkAction(ctx, network, tracked, "network", "disconnect", network.id, tracked.id) +} + +// runNetworkAction runs one fixed network invocation and rewrites engine +// errors so exact network and container identities never reach the player. +func (e *environment) runNetworkAction(ctx context.Context, network *trackedNetwork, container *trackedContainer, args ...string) error { + identities := []identity{network.identity()} + if container != nil { + identities = append(identities, container.identity()) + } + _, err := e.runRedacted(ctx, identities, args...) + return err +} diff --git a/internal/dockerlab/network_errors_test.go b/internal/dockerlab/network_errors_test.go new file mode 100644 index 0000000..ba5f178 --- /dev/null +++ b/internal/dockerlab/network_errors_test.go @@ -0,0 +1,101 @@ +package dockerlab + +import ( + "context" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func TestNetworkErrorsRedactExactIdentity(t *testing.T) { + network := &trackedNetwork{id: "feed" + strings.Repeat("9", 60), logicalID: "net-01", name: "db-net", actualName: "opsquest-" + strings.Repeat("a", 24) + "-n01"} + message := "network " + network.id + " (" + network.id[:12] + ") named " + network.actualName + " failed" + got := redact(message, network.identity()) + if strings.Contains(got, network.id[:12]) || strings.Contains(got, network.actualName) || !strings.Contains(got, "net-01") || !strings.Contains(got, "db-net") { + t.Fatalf("redactNetworkIdentifiers() = %q", got) + } +} + +func TestMissingNetworkClassificationIsReferenceSpecific(t *testing.T) { + name := "opsquest-" + strings.Repeat("a", 24) + "-n01" + id := "feed" + strings.Repeat("0", 59) + "1" + tests := []struct { + name string + stderr string + reference string + want bool + }{ + {name: "daemon missing name", stderr: "Error response from daemon: network " + name + " not found", reference: name, want: true}, + {name: "daemon missing ID", stderr: "Error response from daemon: network " + id + " not found", reference: id, want: true}, + {name: "legacy CLI wording", stderr: "Error: No such network: " + name, reference: name, want: true}, + {name: "context not found", stderr: contextNotFoundMessage, reference: name}, + {name: "different network", stderr: "Error response from daemon: network other-net not found", reference: name}, + {name: "daemon unreachable", stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", reference: name}, + {name: "empty reference", stderr: "Error response from daemon: network not found", reference: ""}, + } + for _, test := range tests { + if got := isMissingNetwork(runResult{stderr: test.stderr}, test.reference); got != test.want { + t.Errorf("%s: isMissingNetwork() = %v, want %v", test.name, got, test.want) + } + } +} + +func TestContextFailureNeitherPassesValidationNorDropsNetworkCleanup(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createEnvironmentForMission(t, commandRunner, testNetworkDockerMission()) + networks := commandRunner.networkCount() + if networks != 3 { + t.Fatalf("fixture networks = %d, want 3", networks) + } + + commandRunner.setContextFailure(true) + absent := mission.Condition{Type: mission.ConditionDockerNetworkAbsent, Network: "db-net"} + if got, err := environment.Observe(context.Background(), absent); err == nil || got || !strings.Contains(err.Error(), "context not found") { + t.Fatalf("Observe(network absent) during context failure = %v, %v; want error", got, err) + } + shared := networkCondition(mission.ConditionDockerNetworkShared, "web", "api") + if got, err := environment.Observe(context.Background(), shared); err == nil || got { + t.Fatalf("Observe(shared) during context failure = %v, %v; want error", got, err) + } + if err := environment.Close(); err == nil { + t.Fatal("Close() during context failure reported success") + } + if commandRunner.networkCount() != networks { + t.Fatalf("networks changed during failed Close: %d", commandRunner.networkCount()) + } + + commandRunner.setContextFailure(false) + if err := environment.Close(); err != nil { + t.Fatalf("Close() after context recovery = %v", err) + } + if commandRunner.networkCount() != 0 || commandRunner.containerCount() != 0 { + t.Fatalf("resources leaked after recovered Close: %d networks, %d containers", commandRunner.networkCount(), commandRunner.containerCount()) + } +} + +func TestJanitorPropagatesNetworkRemovalFailures(t *testing.T) { + commandRunner := newFakeDockerRunner() + session := strings.Repeat("1", 24) + orphan := "feed" + strings.Repeat("1", 60) + commandRunner.seedNetwork(orphan, &fakeNetwork{labels: orphanLabels(session, "999", testOwnerHost), name: "opsquest-" + session + "-n01", created: testNow}) + factory := testFactory(commandRunner) + + commandRunner.mutex.Lock() + commandRunner.contextFailureOnNetworkRemove = true + commandRunner.mutex.Unlock() + removed, err := factory.RemoveOrphanedResources(context.Background()) + if err == nil || !strings.Contains(err.Error(), "context not found") || removed != 0 { + t.Fatalf("RemoveOrphanedResources() during context failure = %d, %v; want error and nothing counted", removed, err) + } + if !commandRunner.hasNetwork(orphan) { + t.Fatal("orphaned network disappeared during a failed removal") + } + + commandRunner.mutex.Lock() + commandRunner.contextFailureOnNetworkRemove = false + commandRunner.mutex.Unlock() + if removed, err := factory.RemoveOrphanedResources(context.Background()); err != nil || removed != 1 || commandRunner.hasNetwork(orphan) { + t.Fatalf("RemoveOrphanedResources() after recovery = %d, %v", removed, err) + } +} diff --git a/internal/dockerlab/network_test.go b/internal/dockerlab/network_test.go index b00d371..4b260e5 100644 --- a/internal/dockerlab/network_test.go +++ b/internal/dockerlab/network_test.go @@ -382,95 +382,3 @@ func TestJanitorSweepsOrphanedNetworksAfterContainers(t *testing.T) { t.Fatal("janitor removed the wrong networks") } } - -func TestNetworkErrorsRedactExactIdentity(t *testing.T) { - network := &trackedNetwork{id: "feed" + strings.Repeat("9", 60), logicalID: "net-01", name: "db-net", actualName: "opsquest-" + strings.Repeat("a", 24) + "-n01"} - message := "network " + network.id + " (" + network.id[:12] + ") named " + network.actualName + " failed" - got := redactNetworkIdentifiers(message, network) - if strings.Contains(got, network.id[:12]) || strings.Contains(got, network.actualName) || !strings.Contains(got, "net-01") || !strings.Contains(got, "db-net") { - t.Fatalf("redactNetworkIdentifiers() = %q", got) - } -} - -func TestMissingNetworkClassificationIsReferenceSpecific(t *testing.T) { - name := "opsquest-" + strings.Repeat("a", 24) + "-n01" - id := "feed" + strings.Repeat("0", 59) + "1" - tests := []struct { - name string - stderr string - reference string - want bool - }{ - {name: "daemon missing name", stderr: "Error response from daemon: network " + name + " not found", reference: name, want: true}, - {name: "daemon missing ID", stderr: "Error response from daemon: network " + id + " not found", reference: id, want: true}, - {name: "legacy CLI wording", stderr: "Error: No such network: " + name, reference: name, want: true}, - {name: "context not found", stderr: contextNotFoundMessage, reference: name}, - {name: "different network", stderr: "Error response from daemon: network other-net not found", reference: name}, - {name: "daemon unreachable", stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", reference: name}, - {name: "empty reference", stderr: "Error response from daemon: network not found", reference: ""}, - } - for _, test := range tests { - if got := isMissingNetwork(runResult{stderr: test.stderr}, test.reference); got != test.want { - t.Errorf("%s: isMissingNetwork() = %v, want %v", test.name, got, test.want) - } - } -} - -func TestContextFailureNeitherPassesValidationNorDropsNetworkCleanup(t *testing.T) { - commandRunner := newFakeDockerRunner() - environment := createEnvironmentForMission(t, commandRunner, testNetworkDockerMission()) - networks := commandRunner.networkCount() - if networks != 3 { - t.Fatalf("fixture networks = %d, want 3", networks) - } - - commandRunner.setContextFailure(true) - absent := mission.Condition{Type: mission.ConditionDockerNetworkAbsent, Network: "db-net"} - if got, err := environment.Observe(context.Background(), absent); err == nil || got || !strings.Contains(err.Error(), "context not found") { - t.Fatalf("Observe(network absent) during context failure = %v, %v; want error", got, err) - } - shared := networkCondition(mission.ConditionDockerNetworkShared, "web", "api") - if got, err := environment.Observe(context.Background(), shared); err == nil || got { - t.Fatalf("Observe(shared) during context failure = %v, %v; want error", got, err) - } - if err := environment.Close(); err == nil { - t.Fatal("Close() during context failure reported success") - } - if commandRunner.networkCount() != networks { - t.Fatalf("networks changed during failed Close: %d", commandRunner.networkCount()) - } - - commandRunner.setContextFailure(false) - if err := environment.Close(); err != nil { - t.Fatalf("Close() after context recovery = %v", err) - } - if commandRunner.networkCount() != 0 || commandRunner.containerCount() != 0 { - t.Fatalf("resources leaked after recovered Close: %d networks, %d containers", commandRunner.networkCount(), commandRunner.containerCount()) - } -} - -func TestJanitorPropagatesNetworkRemovalFailures(t *testing.T) { - commandRunner := newFakeDockerRunner() - session := strings.Repeat("1", 24) - orphan := "feed" + strings.Repeat("1", 60) - commandRunner.seedNetwork(orphan, &fakeNetwork{labels: orphanLabels(session, "999", testOwnerHost), name: "opsquest-" + session + "-n01", created: testNow}) - factory := testFactory(commandRunner) - - commandRunner.mutex.Lock() - commandRunner.contextFailureOnNetworkRemove = true - commandRunner.mutex.Unlock() - removed, err := factory.RemoveOrphanedResources(context.Background()) - if err == nil || !strings.Contains(err.Error(), "context not found") || removed != 0 { - t.Fatalf("RemoveOrphanedResources() during context failure = %d, %v; want error and nothing counted", removed, err) - } - if !commandRunner.hasNetwork(orphan) { - t.Fatal("orphaned network disappeared during a failed removal") - } - - commandRunner.mutex.Lock() - commandRunner.contextFailureOnNetworkRemove = false - commandRunner.mutex.Unlock() - if removed, err := factory.RemoveOrphanedResources(context.Background()); err != nil || removed != 1 || commandRunner.hasNetwork(orphan) { - t.Fatalf("RemoveOrphanedResources() after recovery = %d, %v", removed, err) - } -} diff --git a/internal/dockerlab/observe.go b/internal/dockerlab/observe.go new file mode 100644 index 0000000..77f4a32 --- /dev/null +++ b/internal/dockerlab/observe.go @@ -0,0 +1,121 @@ +package dockerlab + +import ( + "context" + "fmt" + "strings" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func (e *environment) Observe(ctx context.Context, condition mission.Condition) (bool, error) { + if err := e.ready(ctx); err != nil { + return false, err + } + switch condition.Type { + case mission.ConditionDockerContainerRunning: + tracked, exists := e.container(condition.Container) + if !exists { + return false, nil + } + inspection, exists, err := e.inspect(ctx, tracked.id) + return exists && inspection.State.Running, err + case mission.ConditionDockerContainerStopped: + tracked, exists := e.container(condition.Container) + if !exists { + return false, nil + } + inspection, exists, err := e.inspect(ctx, tracked.id) + return exists && !inspection.State.Running, err + case mission.ConditionDockerContainerAbsent: + tracked, exists := e.container(condition.Container) + if !exists { + return false, nil + } + _, exists, err := e.inspect(ctx, tracked.id) + return err == nil && !exists, err + case mission.ConditionDockerNetworkShared, mission.ConditionDockerNetworkIsolated: + if len(condition.Containers) != 2 { + return false, fmt.Errorf("%s requires exactly two containers", condition.Type) + } + shared, bothExist, err := e.shareNetwork(ctx, condition.Containers[0], condition.Containers[1]) + if err != nil || !bothExist { + return false, err + } + return shared == (condition.Type == mission.ConditionDockerNetworkShared), nil + case mission.ConditionDockerNetworkAbsent: + return e.networkAbsent(ctx, condition.Network) + case mission.ConditionDockerContainerCountEqual: + if condition.Count == nil { + return false, fmt.Errorf("docker_container_count_equals requires count") + } + count, err := e.countOwnedContainers(ctx) + if err != nil { + return false, err + } + return count == *condition.Count, nil + default: + return false, fmt.Errorf("unknown validation type %q for Docker environment", condition.Type) + } +} + +func (e *environment) countOwnedContainers(ctx context.Context) (int, error) { + result, err := e.run(ctx, + "container", "ls", "--all", "--quiet", "--no-trunc", + "--filter", "label="+sessionLabel+"="+e.sessionID, + ) + if err != nil { + return 0, err + } + ids := strings.Fields(result.stdout) + count := 0 + for _, id := range ids { + if !containerIDPattern.MatchString(id) { + return 0, fmt.Errorf("Docker container listing returned an invalid container ID") + } + inspection, exists, err := e.inspect(ctx, id) + if err != nil { + return 0, err + } + if !exists { + continue + } + labels := inspection.Config.Labels + if e.ownedByAttempt(labels) { + count++ + } + } + return count, nil +} + +// shareNetwork reports whether both containers exist and have at least one +// attempt network in common. Both results are false when either is gone. +func (e *environment) shareNetwork(ctx context.Context, first, second string) (shared, bothExist bool, err error) { + var inspections [2]containerInspection + for index, alias := range []string{first, second} { + tracked, exists := e.container(alias) + if !exists { + return false, false, nil + } + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil || !exists { + return false, false, err + } + inspections[index] = inspection + } + for _, network := range e.snapshotNetworks() { + if network.id != "" && attachedTo(inspections[0], network) && attachedTo(inspections[1], network) { + return true, true, nil + } + } + return false, true, nil +} + +func (e *environment) networkAbsent(ctx context.Context, name string) (bool, error) { + network, exists := e.network(name) + if !exists { + return false, nil + } + _, exists, err := e.inspectNetwork(ctx, network) + return err == nil && !exists, err +} diff --git a/internal/dockerlab/ownership.go b/internal/dockerlab/ownership.go new file mode 100644 index 0000000..36b43b3 --- /dev/null +++ b/internal/dockerlab/ownership.go @@ -0,0 +1,71 @@ +package dockerlab + +import ( + "os" + "strconv" + "time" +) + +// Every engine resource an attempt creates carries these labels. Cleanup and +// the orphan janitor remove a resource only when all of them match. +const ( + managedLabel = "com.opsquest.managed" + schemaLabel = "com.opsquest.schema" + sessionLabel = "com.opsquest.session" + missionLabel = "com.opsquest.mission" + aliasLabel = "com.opsquest.alias" + // Owner labels let a later process recognize resources whose OpsQuest + // process exited without cleanup. They are advisory: removal still + // requires every ownership label above to match. + ownerPIDLabel = "com.opsquest.owner-pid" + ownerHostLabel = "com.opsquest.owner-host" +) + +// processOwner identifies the current OpsQuest process for orphan detection +// and decides whether another recorded owner process is still alive. +type processOwner struct { + pid int + host string + alive func(int) bool + now func() time.Time +} + +func currentProcessOwner() processOwner { + host, err := os.Hostname() + if err != nil { + host = "" + } + return processOwner{pid: os.Getpid(), host: host, alive: processAlive, now: time.Now} +} + +// ownershipLabels returns the --label arguments for one attempt resource +// with the given logical alias. +func (e *environment) ownershipLabels(alias string) []string { + args := []string{ + "--label", managedLabel + "=true", + "--label", schemaLabel + "=1", + "--label", sessionLabel + "=" + e.sessionID, + "--label", missionLabel + "=" + e.missionID, + "--label", aliasLabel + "=" + alias, + } + if e.owner.host != "" && e.owner.pid > 0 { + args = append(args, + "--label", ownerPIDLabel+"="+strconv.Itoa(e.owner.pid), + "--label", ownerHostLabel+"="+e.owner.host, + ) + } + return args +} + +// ownedByAttempt reports whether labels prove a resource belongs to this +// attempt. +func (e *environment) ownedByAttempt(labels map[string]string) bool { + return labels[managedLabel] == "true" && labels[schemaLabel] == "1" && + labels[sessionLabel] == e.sessionID && labels[missionLabel] == e.missionID +} + +// owns reports whether labels prove a resource is this attempt's resource +// with the given logical alias. +func (e *environment) owns(labels map[string]string, alias string) bool { + return e.ownedByAttempt(labels) && labels[aliasLabel] == alias +} diff --git a/internal/dockerlab/parser.go b/internal/dockerlab/parser.go index e167e38..4d14902 100644 --- a/internal/dockerlab/parser.go +++ b/internal/dockerlab/parser.go @@ -83,6 +83,12 @@ type dockerAction struct { network string } +// aliasActions maps single-alias container subcommands to their actions. +var aliasActions = map[string]actionKind{"start": actionStart, "restart": actionRestart, "stop": actionStop, "inspect": actionInspect} + +// networkActions maps single-network subcommands to their actions. +var networkActions = map[string]actionKind{"create": actionNetworkCreate, "rm": actionNetworkRemove, "remove": actionNetworkRemove, "inspect": actionNetworkInspect} + var listFilterValues = map[string]map[string]bool{ "status": {"created": true, "running": true, "restarting": true, "exited": true}, "health": {"starting": true, "healthy": true, "unhealthy": true, "none": true}, @@ -140,15 +146,7 @@ func parseAction(line string) (dockerAction, error) { if len(fields) != 2 || !mission.ValidDockerLogicalName(fields[1]) { return dockerAction{}, fmt.Errorf("usage: docker %s ALIAS", fields[0]) } - kind := actionStart - if fields[0] == "restart" { - kind = actionRestart - } else if fields[0] == "stop" { - kind = actionStop - } else if fields[0] == "inspect" { - kind = actionInspect - } - return dockerAction{kind: kind, alias: fields[1]}, nil + return dockerAction{kind: aliasActions[fields[0]], alias: fields[1]}, nil default: return dockerAction{}, fmt.Errorf("docker %s is outside this mission's teaching subset; type help", fields[0]) } @@ -189,8 +187,7 @@ func parseNetwork(fields []string) (dockerAction, error) { if err := name(fields[1]); err != nil { return dockerAction{}, err } - kind := map[string]actionKind{"create": actionNetworkCreate, "rm": actionNetworkRemove, "remove": actionNetworkRemove, "inspect": actionNetworkInspect}[fields[0]] - return dockerAction{kind: kind, network: fields[1]}, nil + return dockerAction{kind: networkActions[fields[0]], network: fields[1]}, nil case "connect", "disconnect": if len(fields) != 3 { return dockerAction{}, fmt.Errorf("usage: docker network %s NETWORK ALIAS", fields[0]) diff --git a/internal/dockerlab/setup_test.go b/internal/dockerlab/setup_test.go new file mode 100644 index 0000000..a382685 --- /dev/null +++ b/internal/dockerlab/setup_test.go @@ -0,0 +1,189 @@ +package dockerlab + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func TestCreateUsesExactLabelsAndResourceLimits(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + calls := commandRunner.snapshotCalls() + if len(calls) < 6 { + t.Fatalf("setup calls = %q", calls) + } + wantCreate := []string{ + "container", "create", + "--pull", "never", + "--name", "opsquest-aaaaaaaaaaaaaaaaaaaaaaaa-c01", + "--label", "com.opsquest.managed=true", + "--label", "com.opsquest.schema=1", + "--label", "com.opsquest.session=aaaaaaaaaaaaaaaaaaaaaaaa", + "--label", "com.opsquest.mission=docker-container-census", + "--label", "com.opsquest.alias=api", + "--label", "com.opsquest.owner-pid=4242", + "--label", "com.opsquest.owner-host=test-host", + "--network", "none", + "--read-only", + "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=8m", + "--user", "65532:65532", + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + "--pids-limit", "64", + "--memory", "128m", + "--memory-swap", "128m", + "--cpus", "0.5", + "--ulimit", "nofile=256:256", + "--restart", "no", + "--stop-timeout", "1", + "--entrypoint", "/bin/sleep", + testImageReference, + "86400", + } + createIndex, create := firstCall(t, calls, "container", "create") + if !reflect.DeepEqual(create, wantCreate) { + t.Fatalf("first create args:\n got: %q\nwant: %q", create, wantCreate) + } + metricsID := environment.byAlias["metrics"].id + if !reflect.DeepEqual(calls[createIndex+2], []string{"container", "start", metricsID}) { + t.Fatalf("running fixture start = %q", calls[createIndex+2]) + } + for _, call := range calls { + joined := strings.Join(call, " ") + for _, forbidden := range []string{"--privileged", "--volume", "--mount", "/var/run/docker.sock", "--network host"} { + if strings.Contains(joined, forbidden) { + t.Errorf("unsafe argument %q in %q", forbidden, call) + } + } + } +} + +func TestDiagnosticFixtureUsesFixedCommandAndExposesSanitizedObservations(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createEnvironmentForMission(t, commandRunner, testDiagnosticDockerMission()) + calls := commandRunner.snapshotCalls() + if len(calls) < 6 { + t.Fatalf("setup calls = %q", calls) + } + logText := "ERROR: literal $(touch /host); still data" + wantTail := []string{ + "--entrypoint", "/bin/sh", + testImageReference, + "-c", `printf '%s\n' "$1"; exit "$2"`, + "opsquest-diagnostic", logText, "23", + } + createIndex, create := firstCall(t, calls, "container", "create") + if len(create) < len(wantTail) || !reflect.DeepEqual(create[len(create)-len(wantTail):], wantTail) { + t.Fatalf("diagnostic create tail:\n got: %q\nwant: %q", create, wantTail) + } + if strings.Contains(create[len(create)-4], logText) { + t.Fatalf("diagnostic data was interpolated into shell program: %q", create[len(create)-4]) + } + jobID := environment.byAlias["job"].id + if !reflect.DeepEqual(calls[createIndex+1], []string{"container", "start", jobID}) || !reflect.DeepEqual(calls[createIndex+2], []string{"container", "wait", jobID}) { + t.Fatalf("diagnostic lifecycle calls = %q", calls[createIndex+1:createIndex+3]) + } + + logged, err := environment.Execute(context.Background(), "docker container logs job") + if err != nil || logged.Output != logText+"\n" { + t.Fatalf("logs output = %q, error = %v", logged.Output, err) + } + inspected, err := environment.Execute(context.Background(), "docker container inspect job") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(inspected.Output, `"Name": "job"`) || !strings.Contains(inspected.Output, `"ExitCode": 23`) || strings.Contains(inspected.Output, jobID) || strings.Contains(inspected.Output, environment.byAlias["job"].actualName) { + t.Fatalf("sanitized diagnostic inspection:\n%s", inspected.Output) + } + stopped := mission.Condition{Type: mission.ConditionDockerContainerStopped, Container: "job"} + if got, err := environment.Observe(context.Background(), stopped); err != nil || !got { + t.Fatalf("diagnostic stopped outcome = %v, %v", got, err) + } +} + +func TestSetupFailureCleansAlreadyCreatedContainers(t *testing.T) { + t.Run("later create fails", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.failCreateAt = 2 + if _, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()); err == nil { + t.Fatal("Create() unexpectedly succeeded") + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after failed setup = %d", commandRunner.containerCount()) + } + }) + + t.Run("ambiguous create is recovered by owned name", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.failAfterCreateAt = 2 + if _, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()); err == nil { + t.Fatal("Create() unexpectedly succeeded") + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after ambiguous create = %d", commandRunner.containerCount()) + } + calls := commandRunner.snapshotCalls() + ambiguousName := "opsquest-aaaaaaaaaaaaaaaaaaaaaaaa-c02" + foundRecoveryInspect := false + for _, call := range calls { + if reflect.DeepEqual(call, []string{"container", "inspect", "--format", "{{json .}}", ambiguousName}) { + foundRecoveryInspect = true + } + } + if !foundRecoveryInspect { + t.Fatalf("ambiguous create did not reconcile by generated name: %q", calls) + } + }) + + t.Run("transient ambiguous-create inspection remains cleanup eligible", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.failAfterCreateAt = 2 + ambiguousName := "opsquest-aaaaaaaaaaaaaaaaaaaaaaaa-c02" + commandRunner.failNextInspect[ambiguousName] = errors.New("temporary inspect failure") + + partial, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()) + if err == nil || partial == nil { + t.Fatalf("Create() = environment %T, error %v; want partial environment and error", partial, err) + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after retried reconciliation cleanup = %d", commandRunner.containerCount()) + } + if err := partial.Close(); err != nil { + t.Fatalf("Close() after setup cleanup = %v", err) + } + }) + + t.Run("delayed ambiguous create remains cleanup eligible", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.failAfterCreateAt = 2 + ambiguousName := "opsquest-aaaaaaaaaaaaaaaaaaaaaaaa-c02" + commandRunner.hideNextInspect[ambiguousName] = true + + partial, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()) + if err == nil || partial == nil { + t.Fatalf("Create() = environment %T, error %v; want partial environment and error", partial, err) + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after delayed reconciliation cleanup = %d", commandRunner.containerCount()) + } + if err := partial.Close(); err != nil { + t.Fatalf("Close() after setup cleanup = %v", err) + } + }) + + t.Run("fixture start fails", func(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.failNextStart = true + if _, err := testFactory(commandRunner).Create(context.Background(), testDockerMission()); err == nil { + t.Fatal("Create() unexpectedly succeeded") + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers after failed start = %d", commandRunner.containerCount()) + } + }) +} diff --git a/internal/dockerlab/transport.go b/internal/dockerlab/transport.go new file mode 100644 index 0000000..0d380f0 --- /dev/null +++ b/internal/dockerlab/transport.go @@ -0,0 +1,101 @@ +package dockerlab + +import ( + "context" + "fmt" + "strings" +) + +func (e *environment) run(ctx context.Context, args ...string) (runResult, error) { + return runDocker(ctx, e.runner, args...) +} + +// runDocker runs one fixed Docker invocation with the standard operation +// timeout and a bounded error summary. +func runDocker(ctx context.Context, commandRunner runner, args ...string) (runResult, error) { + operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) + defer cancel() + result, err := commandRunner.run(operationCtx, args...) + if err != nil { + return result, fmt.Errorf("Docker command failed%s", dockerFailureDetail(result, err)) + } + return result, nil +} + +// runRedacted runs one fixed invocation and rewrites a failure so none of +// the given resources' engine identities reach the player. +func (e *environment) runRedacted(ctx context.Context, identities []identity, args ...string) (runResult, error) { + operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) + defer cancel() + result, err := e.runner.run(operationCtx, args...) + if err != nil { + return result, fmt.Errorf("Docker command failed%s", redact(dockerFailureDetail(result, err), identities...)) + } + return result, nil +} + +// identity pairs a resource's exact engine identifiers with the logical names +// players see. +type identity struct { + id string + actualName string + logicalID string + name string +} + +func (t *trackedContainer) identity() identity { + return identity{id: t.id, actualName: t.actualName, logicalID: t.logicalID, name: t.alias} +} + +func (n *trackedNetwork) identity() identity { + return identity{id: n.id, actualName: n.actualName, logicalID: n.logicalID, name: n.name} +} + +// redact replaces exact engine IDs, including their 12-character short form, +// and generated names with logical identities in player-visible text. +func redact(message string, identities ...identity) string { + for _, resource := range identities { + if resource.id != "" { + message = strings.ReplaceAll(message, resource.id, resource.logicalID) + if len(resource.id) >= 12 { + message = strings.ReplaceAll(message, resource.id[:12], resource.logicalID) + } + } + message = strings.ReplaceAll(message, resource.actualName, resource.name) + } + return message +} + +func isMissingContainer(result runResult) bool { + message := strings.ToLower(result.stderr + "\n" + result.stdout) + return strings.Contains(message, "no such container") || strings.Contains(message, "no such object") +} + +// isMissingNetwork reports whether Docker said that exactly reference does +// not exist. It accepts only the engine's network-specific wording that names +// the reference, so CLI, context, and transport failures such as +// `context "x": context not found` propagate as errors instead of looking +// like a removed network. +func isMissingNetwork(result runResult, reference string) bool { + if reference == "" { + return false + } + message := strings.ToLower(result.stderr + "\n" + result.stdout) + reference = strings.ToLower(reference) + return strings.Contains(message, "network "+reference+" not found") || + strings.Contains(message, "no such network: "+reference) +} + +func dockerFailureDetail(result runResult, err error) string { + message := strings.TrimSpace(result.stderr) + if message == "" && err != nil { + message = err.Error() + } + if message == "" { + return "" + } + if len(message) > 240 { + message = message[:240] + "…" + } + return ": " + message +} diff --git a/internal/dockerlab/transport_test.go b/internal/dockerlab/transport_test.go new file mode 100644 index 0000000..9395d6d --- /dev/null +++ b/internal/dockerlab/transport_test.go @@ -0,0 +1,15 @@ +package dockerlab + +import ( + "testing" +) + +func TestLimitedBufferCapsCapturedOutput(t *testing.T) { + buffer := newLimitedBuffer(4) + if count, err := buffer.Write([]byte("abcdef")); err != nil || count != 6 { + t.Fatalf("Write() = %d, %v", count, err) + } + if got := buffer.String(); got != "abcd" || !buffer.exceeded { + t.Fatalf("limited buffer = %q, exceeded=%v", got, buffer.exceeded) + } +} diff --git a/internal/dockerlab/triage_test.go b/internal/dockerlab/triage_test.go index 5aa4063..2b69e07 100644 --- a/internal/dockerlab/triage_test.go +++ b/internal/dockerlab/triage_test.go @@ -174,7 +174,7 @@ func TestRemoveOnlyStoppedContainersAndHideRemovedIdentity(t *testing.T) { func TestEngineErrorsRedactExactIdentity(t *testing.T) { tracked := &trackedContainer{id: strings.Repeat("c", 64), logicalID: "lab-01", alias: "api", actualName: "opsquest-" + strings.Repeat("a", 24) + "-c01"} message := "Error: cannot start " + tracked.id + " (" + tracked.id[:12] + ") named " + tracked.actualName - got := redactIdentifiers(message, tracked) + got := redact(message, tracked.identity()) if strings.Contains(got, tracked.id[:12]) || strings.Contains(got, tracked.actualName) || !strings.Contains(got, "lab-01") || !strings.Contains(got, "api") { t.Fatalf("redactIdentifiers() = %q", got) } diff --git a/internal/game/companion.go b/internal/game/companion.go index d571cad..6099be1 100644 --- a/internal/game/companion.go +++ b/internal/game/companion.go @@ -1,6 +1,10 @@ package game -import "slices" +import ( + "slices" + + "github.com/aleksandergregersen/opsquest/internal/profile" +) // AttemptEventType identifies one presentation-safe mission lifecycle update. // Companion consumers may render these events, but they never participate in @@ -94,3 +98,57 @@ func CloneAttemptEvent(event AttemptEvent) AttemptEvent { event.Snapshot = cloneAttemptSnapshot(event.Snapshot) return event } + +func (s Session) reportAttempt(eventType AttemptEventType, state string, outcomes []outcomeResult, hintsUsed int, replaying bool, xp int, firstCompletion bool, practiced, discovered []string, unlocked []profile.Achievement) { + if s.Reporter == nil { + return + } + placement, _ := s.Catalog.Placement(s.Mission.ID) + revealedCount := min(hintsUsed, len(s.Mission.Hints)) + revealedHints := slices.Clone(s.Mission.Hints[:revealedCount]) + publicOutcomes := make([]AttemptOutcome, len(outcomes)) + for index, outcome := range outcomes { + publicOutcomes[index] = AttemptOutcome{Description: outcome.Description, Satisfied: outcome.Satisfied} + } + achievements := make([]string, len(unlocked)) + for index, achievement := range unlocked { + achievements[index] = achievement.Title + } + explanation := "" + if state == AttemptStateCompleted { + explanation = s.Mission.Explanation + } + s.Reporter.ReportAttempt(AttemptEvent{ + Type: eventType, + Snapshot: AttemptSnapshot{ + MissionID: s.Mission.ID, + Number: s.Mission.Number, + Title: s.Mission.Title, + Track: s.Mission.EffectiveTrack(), + WorldNumber: placement.WorldNumber, + WorldTotal: placement.WorldTotal, + WorldName: placement.WorldName, + StageNumber: placement.StageNumber, + StageTotal: placement.StageTotal, + Difficulty: s.Mission.Difficulty, + Story: s.Mission.Story, + Objective: s.Mission.Objective, + SuggestedCommands: slices.Clone(s.Mission.SuggestedCommands), + RevealedHints: revealedHints, + HintCount: len(s.Mission.Hints), + HintsUsed: hintsUsed, + Outcomes: publicOutcomes, + SatisfiedOutcomes: satisfiedOutcomeCount(outcomes), + RewardAvailable: AdjustedReward(s.Mission, hintsUsed), + BaseReward: s.Mission.Rewards.XP, + Replaying: replaying, + State: state, + Explanation: explanation, + XPAwarded: xp, + FirstCompletion: firstCompletion, + PracticedCommands: slices.Clone(practiced), + DiscoveredCommands: slices.Clone(discovered), + UnlockedAchievements: achievements, + }, + }) +} diff --git a/internal/game/docker_missions_test.go b/internal/game/docker_missions_test.go new file mode 100644 index 0000000..a4ff2c9 --- /dev/null +++ b/internal/game/docker_missions_test.go @@ -0,0 +1,125 @@ +package game + +import ( + "context" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func TestContainerCensusAcceptsAlternativeAndRejectsIncompleteOutcomes(t *testing.T) { + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("docker-container-census") + + t.Run("alternative container syntax", func(t *testing.T) { + environment := newMissionDockerEnvironment(item) + for _, command := range []string{"docker container ls --all", "docker container start api"} { + if _, err := environment.Execute(context.Background(), command); err != nil { + t.Fatal(err) + } + } + outcomes, err := evaluateOutcomes(context.Background(), item.Validation, environment, "") + if err != nil || !allOutcomesSatisfied(outcomes) { + t.Fatalf("alternative outcome complete = %v, error = %v", allOutcomesSatisfied(outcomes), err) + } + }) + + t.Run("healthy container only is incomplete", func(t *testing.T) { + environment := newMissionDockerEnvironment(item) + _, _ = environment.Execute(context.Background(), "docker start metrics") + outcomes, err := evaluateOutcomes(context.Background(), item.Validation, environment, "") + if err != nil { + t.Fatal(err) + } + if allOutcomesSatisfied(outcomes) { + t.Fatal("starting only the already-healthy container completed the mission") + } + }) + + t.Run("replacement count is incomplete", func(t *testing.T) { + environment := newMissionDockerEnvironment(item) + environment.running["api"] = true + environment.count = 3 + outcomes, err := evaluateOutcomes(context.Background(), item.Validation, environment, "") + if err != nil { + t.Fatal(err) + } + if allOutcomesSatisfied(outcomes) { + t.Fatal("an extra replacement container completed the mission") + } + }) +} + +func TestNewDockerMissionsAcceptAlternativesAndRejectIncompleteOutcomes(t *testing.T) { + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + tests := []struct { + id string + alternative []string + incomplete []string + }{ + {id: "docker-last-broadcast", alternative: []string{"docker container logs checkout"}, incomplete: []string{"docker inspect checkout"}}, + {id: "docker-exit-code-detective", alternative: []string{"docker container inspect migrate"}, incomplete: []string{"docker inspect seed"}}, + {id: "docker-quiet-worker", alternative: []string{"docker container stop worker"}, incomplete: []string{"docker stop metrics"}}, + {id: "docker-recovery-pair", alternative: []string{"docker container start backend", "docker container start frontend"}, incomplete: []string{"docker start frontend"}}, + {id: "docker-shift-handoff", alternative: []string{"docker container stop retiring", "docker container start standby"}, incomplete: []string{"docker start standby"}}, + {id: "docker-janitor-duty", alternative: []string{"docker container rm report-weekly", "docker container rm backup-0915", "docker container rm backup-0914"}, incomplete: []string{"docker rm backup-0914", "docker rm backup-0915"}}, + {id: "docker-janitor-duty", alternative: []string{"docker rm backup-0914", "docker rm backup-0915", "docker rm report-weekly", "docker ps -a"}, incomplete: []string{"docker stop worker", "docker rm worker", "docker rm backup-0914", "docker rm backup-0915", "docker rm report-weekly"}}, + {id: "docker-tail-end", alternative: []string{"docker container logs -n 3 ledger"}, incomplete: []string{"docker logs ledger"}}, + {id: "docker-tail-end", alternative: []string{"docker logs ledger", "docker logs --tail 3 ledger"}, incomplete: []string{"docker logs --tail 4 ledger"}}, + {id: "docker-running-isnt-healthy", alternative: []string{"docker ps --filter health=unhealthy", "docker container start web-c", "docker container stop web-b"}, incomplete: []string{"docker stop web-a", "docker start web-c"}}, + {id: "docker-running-isnt-healthy", alternative: []string{"docker stop web-b", "docker start web-c", "docker ps"}, incomplete: []string{"docker stop web-b", "docker rm web-b", "docker start web-c"}}, + {id: "docker-crash-loop", alternative: []string{"docker inspect payments", "docker container stop payments", "docker logs --tail 1 payments"}, incomplete: []string{"docker logs payments"}}, + {id: "docker-crash-loop", alternative: []string{"docker stop payments", "docker ps -a", "docker container logs payments"}, incomplete: []string{"docker stop payments", "docker logs payments", "docker rm payments"}}, + {id: "docker-postmortem-triage", alternative: []string{"docker ps --filter status=exited", "docker rm sync-users", "docker container rm sync-orders", "docker ps --filter health=unhealthy", "docker container stop api-canary"}, incomplete: []string{"docker stop api-canary", "docker rm sync-orders", "docker rm sync-users", "docker rm sync-invoices"}}, + {id: "docker-postmortem-triage", alternative: []string{"docker stop api-canary", "docker rm sync-orders", "docker rm sync-users", "docker logs sync-invoices"}, incomplete: []string{"docker stop api-canary", "docker rm api-canary", "docker rm sync-orders", "docker rm sync-users"}}, + {id: "docker-network-census", alternative: []string{"docker network ls", "docker network inspect cache-net"}, incomplete: []string{"docker network inspect web-net"}}, + {id: "docker-network-census", alternative: []string{"docker network inspect db-net", "docker network inspect cache-net"}, incomplete: []string{"docker network inspect cache-net", "docker inspect api"}}, + {id: "docker-cant-reach-the-db", alternative: []string{"docker network create api-db", "docker network connect api-db api", "docker network connect api-db db"}, incomplete: []string{"docker network connect web-net db"}}, + {id: "docker-cant-reach-the-db", alternative: []string{"docker network connect db-net api", "docker network inspect db-net"}, incomplete: []string{"docker network disconnect web-net api", "docker network connect db-net api"}}, + {id: "docker-need-to-know", alternative: []string{"docker stop batch", "docker network disconnect payments-net batch", "docker start batch"}, incomplete: []string{"docker network disconnect payments-net ledger-db"}}, + {id: "docker-need-to-know", alternative: []string{"docker network inspect app-net", "docker network disconnect payments-net batch"}, incomplete: []string{"docker network disconnect app-net batch"}}, + {id: "docker-private-channel", alternative: []string{"docker network create edge-net", "docker network connect edge-net gateway", "docker network connect edge-net api", "docker network disconnect app-net gateway", "docker network disconnect app-net api"}, incomplete: []string{"docker network create metrics-net", "docker network connect metrics-net exporter", "docker network connect metrics-net collector"}}, + {id: "docker-private-channel", alternative: []string{"docker network create metrics-net", "docker network connect metrics-net collector", "docker network connect metrics-net exporter", "docker network disconnect app-net collector", "docker network disconnect app-net exporter"}, incomplete: []string{"docker network create metrics-net", "docker network connect metrics-net exporter", "docker network connect metrics-net collector", "docker network disconnect app-net exporter", "docker network disconnect app-net collector", "docker network disconnect app-net gateway"}}, + {id: "docker-segmentation", alternative: []string{"docker network create web-net", "docker network connect web-net web", "docker network connect web-net api", "docker network disconnect flat-net web", "docker network disconnect legacy-net old-cron", "docker network rm legacy-net", "docker rm old-cron"}, incomplete: []string{"docker network create db-net", "docker network connect db-net api", "docker network connect db-net db", "docker network disconnect flat-net db", "docker rm old-cron"}}, + {id: "docker-segmentation", alternative: []string{"docker network ls", "docker rm old-cron", "docker network rm legacy-net", "docker network create db-net", "docker network connect db-net db", "docker network connect db-net api", "docker network disconnect flat-net db"}, incomplete: []string{"docker network disconnect flat-net db", "docker rm old-cron", "docker network rm legacy-net"}}, + } + for _, test := range tests { + item, found := catalog.Find(test.id) + if !found { + t.Fatalf("mission %q not found", test.id) + } + run := func(t *testing.T, commands []string) bool { + t.Helper() + environment := newMissionDockerEnvironment(item) + lastOutput := "" + for _, command := range commands { + result, err := environment.Execute(context.Background(), command) + if err != nil { + t.Fatalf("Execute(%q) error = %v", command, err) + } + lastOutput = result.Output + } + outcomes, err := evaluateOutcomes(context.Background(), item.Validation, environment, lastOutput) + if err != nil { + t.Fatal(err) + } + return allOutcomesSatisfied(outcomes) + } + t.Run(test.id+" alternative", func(t *testing.T) { + if !run(t, test.alternative) { + t.Fatal("alternative observable outcome did not complete mission") + } + }) + t.Run(test.id+" incomplete", func(t *testing.T) { + if run(t, test.incomplete) { + t.Fatal("incomplete observable outcome completed mission") + } + }) + } +} diff --git a/internal/game/environment_test.go b/internal/game/environment_test.go index b0ac701..6fd376d 100644 --- a/internal/game/environment_test.go +++ b/internal/game/environment_test.go @@ -4,123 +4,15 @@ import ( "bytes" "context" "errors" - "fmt" - "io" "path/filepath" "strings" "testing" - "time" "github.com/aleksandergregersen/opsquest/internal/mission" "github.com/aleksandergregersen/opsquest/internal/profile" "github.com/aleksandergregersen/opsquest/internal/sandbox" ) -type seamCompletionSource struct { - commands []string - paths map[string][]CompletionCandidate -} - -func (s *seamCompletionSource) CommandNames() []string { - return append([]string(nil), s.commands...) -} - -func (s *seamCompletionSource) PathCandidates(prefix string) []CompletionCandidate { - return append([]CompletionCandidate(nil), s.paths[prefix]...) -} - -type seamEnvironment struct { - prompt string - completions CompletionSource - execute func(context.Context, string) (Execution, error) - observe func(context.Context, mission.Condition) (bool, error) - close func() error - closeCount int -} - -func (e *seamEnvironment) PromptLabel() string { - return e.prompt -} - -func (e *seamEnvironment) Execute(ctx context.Context, line string) (Execution, error) { - if e.execute == nil { - return Execution{}, nil - } - return e.execute(ctx, line) -} - -func (e *seamEnvironment) Observe(ctx context.Context, condition mission.Condition) (bool, error) { - if e.observe == nil { - return false, fmt.Errorf("unexpected observation %s", condition.Type) - } - return e.observe(ctx, condition) -} - -func (e *seamEnvironment) CompletionSource() CompletionSource { - return e.completions -} - -func (e *seamEnvironment) Close() error { - e.closeCount++ - if e.close == nil { - return nil - } - return e.close() -} - -type seamReader struct { - lines []string - index int - endErr error - prompts []string - completions []CompletionSource - edit func(sandbox.EditorRequest, viSaveFunc) error -} - -func (r *seamReader) ReadLine(prompt string, completions CompletionSource) (string, error) { - r.prompts = append(r.prompts, prompt) - r.completions = append(r.completions, completions) - if r.index >= len(r.lines) { - if r.endErr != nil { - return "", r.endErr - } - return "", io.EOF - } - line := r.lines[r.index] - r.index++ - return line, nil -} - -func (r *seamReader) Edit(request sandbox.EditorRequest, save viSaveFunc) error { - if r.edit == nil { - return ErrInteractiveEditor - } - return r.edit(request, save) -} - -func seamCatalogMission(t *testing.T, ref string) (mission.Catalog, mission.Mission) { - t.Helper() - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, found := catalog.Find(ref) - if !found { - t.Fatalf("mission %s not found", ref) - } - return catalog, item -} - -func TestSessionRejectsMissingCoreDependencies(t *testing.T) { - player := profile.New("tester") - if _, err := (Session{}).Run(); err == nil || !strings.Contains(err.Error(), "player profile") { - t.Fatalf("Session without player error = %v", err) - } - if _, err := (Session{Player: &player}).Run(); err == nil || !strings.Contains(err.Error(), "profile persistence") { - t.Fatalf("Session without saver error = %v", err) - } -} - func TestObjectiveRecallsSuggestedCommandsWithoutUsingHint(t *testing.T) { catalog, item := seamCatalogMission(t, "linux-find-logs") player := profile.New("tester") @@ -150,291 +42,6 @@ func TestObjectiveRecallsSuggestedCommandsWithoutUsingHint(t *testing.T) { } } -func TestSessionUsesFactoryContextAndClosesBeforeAwardingXP(t *testing.T) { - catalog, item := seamCatalogMission(t, "1") - player := profile.New("tester") - completionSource := &seamCompletionSource{commands: []string{"solve"}} - type contextKey string - ctx := context.WithValue(context.Background(), contextKey("attempt"), "expected") - closedBeforeXP := false - environment := &seamEnvironment{ - prompt: "/generic", - completions: completionSource, - execute: func(commandContext context.Context, line string) (Execution, error) { - if commandContext.Value(contextKey("attempt")) != "expected" { - return Execution{}, errors.New("session context was not propagated") - } - if line != "solve" { - return Execution{}, fmt.Errorf("unexpected command %q", line) - } - return Execution{Output: "/home/operator\n", PracticedCommands: []string{"solve"}}, nil - }, - observe: func(_ context.Context, condition mission.Condition) (bool, error) { - return false, fmt.Errorf("output condition %s was delegated", condition.Type) - }, - close: func() error { - closedBeforeXP = player.XP == 0 && !player.IsComplete(item.ID) - return nil - }, - } - createCalls := 0 - factory := FactoryFunc(func(factoryContext context.Context, created mission.Mission) (Environment, error) { - createCalls++ - if factoryContext.Value(contextKey("attempt")) != "expected" || created.ID != item.ID { - return nil, errors.New("factory received the wrong context or mission") - } - return environment, nil - }) - reader := &seamReader{lines: []string{"solve"}} - out := &bytes.Buffer{} - session := Session{ - Mission: item, - Player: &player, - Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), - Out: out, - ErrOut: &bytes.Buffer{}, - Reader: reader, - Catalog: catalog, - Context: ctx, - Factory: factory, - Now: func() time.Time { return time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) }, - } - - result, err := session.Run() - if err != nil { - t.Fatal(err) - } - if !result.Completed || result.XPAwarded != 40 || player.XP != 40 { - t.Fatalf("result = %#v, player XP = %d", result, player.XP) - } - if createCalls != 1 || environment.closeCount != 1 || !closedBeforeXP { - t.Fatalf("create calls = %d, close calls = %d, closed before XP = %v", createCalls, environment.closeCount, closedBeforeXP) - } - if len(reader.prompts) != 1 || reader.prompts[0] != "opsquest:/generic$ " { - t.Fatalf("prompts = %#v", reader.prompts) - } - if len(reader.completions) != 1 || reader.completions[0] != completionSource { - t.Fatalf("reader completions = %#v, want factory source", reader.completions) - } - if !strings.Contains(out.String(), "\n/home/operator\n") { - t.Fatalf("execution output was not kept raw: %q", out.String()) - } -} - -func TestSessionClosesEnvironmentOnEveryTerminalPath(t *testing.T) { - catalog, first := seamCatalogMission(t, "1") - _, worldTwoFirst := seamCatalogMission(t, "linux-permissions") - _, stateMission := seamCatalogMission(t, "linux-workspace") - readFailure := errors.New("read failed") - observeFailure := errors.New("observe failed") - - tests := []struct { - name string - item mission.Mission - reader *seamReader - execute func(context.Context, string) (Execution, error) - observe func(context.Context, mission.Condition) (bool, error) - wantQuit bool - wantSwitch string - wantWorld int - wantErrIs error - }{ - {name: "quit", item: first, reader: &seamReader{lines: []string{"quit"}}, wantQuit: true}, - {name: "EOF", item: first, reader: &seamReader{}, wantQuit: true}, - {name: "switch", item: first, reader: &seamReader{lines: []string{"next"}}, wantSwitch: "linux-config-crawl"}, - {name: "stage switch", item: worldTwoFirst, reader: &seamReader{lines: []string{"play 4"}}, wantSwitch: "linux-runaway"}, - {name: "world switch", item: first, reader: &seamReader{lines: []string{"world 2"}}, wantSwitch: "linux-permissions", wantWorld: 2}, - {name: "read error", item: first, reader: &seamReader{endErr: readFailure}, wantErrIs: readFailure}, - { - name: "validation error", - item: stateMission, - reader: &seamReader{lines: []string{"change"}}, - execute: func(context.Context, string) (Execution, error) { - return Execution{}, nil - }, - observe: func(context.Context, mission.Condition) (bool, error) { - return false, observeFailure - }, - wantErrIs: observeFailure, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - environment := &seamEnvironment{prompt: "/test", execute: test.execute, observe: test.observe} - player := profile.New("tester") - session := Session{ - Mission: test.item, - Player: &player, - Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), - Out: &bytes.Buffer{}, - ErrOut: &bytes.Buffer{}, - Reader: test.reader, - Catalog: catalog, - Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { - return environment, nil - }), - } - - result, err := session.Run() - if test.wantErrIs == nil && err != nil { - t.Fatal(err) - } - if test.wantErrIs != nil && !errors.Is(err, test.wantErrIs) { - t.Fatalf("Run() error = %v, want errors.Is(..., %v)", err, test.wantErrIs) - } - if result.Quit != test.wantQuit || result.SwitchMission != test.wantSwitch || result.WorldRoute != test.wantWorld { - t.Fatalf("result = %#v", result) - } - if environment.closeCount != 1 { - t.Fatalf("Close() calls = %d, want 1", environment.closeCount) - } - }) - } -} - -func TestSessionRestartClosesAndRecreatesEnvironment(t *testing.T) { - catalog, item := seamCatalogMission(t, "1") - first := &seamEnvironment{prompt: "/first"} - second := &seamEnvironment{prompt: "/second"} - created := 0 - factory := FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { - created++ - switch created { - case 1: - return first, nil - case 2: - if first.closeCount != 1 { - return nil, fmt.Errorf("first environment was not closed before recreation") - } - return second, nil - default: - return nil, fmt.Errorf("unexpected create call %d", created) - } - }) - reader := &seamReader{lines: []string{"restart", "quit"}} - player := profile.New("tester") - session := Session{ - Mission: item, - Player: &player, - Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), - Out: &bytes.Buffer{}, - ErrOut: &bytes.Buffer{}, - Reader: reader, - Catalog: catalog, - Factory: factory, - } - - result, err := session.Run() - if err != nil { - t.Fatal(err) - } - if !result.Quit || created != 2 || first.closeCount != 1 || second.closeCount != 1 { - t.Fatalf("result = %#v, created = %d, close counts = %d/%d", result, created, first.closeCount, second.closeCount) - } - if len(reader.prompts) != 2 || reader.prompts[0] != "opsquest:/first$ " || reader.prompts[1] != "opsquest:/second$ " { - t.Fatalf("restart prompts = %#v", reader.prompts) - } -} - -func TestSessionCancellationDuringExecutionClosesEnvironment(t *testing.T) { - catalog, item := seamCatalogMission(t, "1") - ctx, cancel := context.WithCancel(context.Background()) - environment := &seamEnvironment{ - prompt: "/test", - execute: func(context.Context, string) (Execution, error) { - cancel() - return Execution{}, context.Canceled - }, - } - player := profile.New("tester") - session := Session{ - Mission: item, - Player: &player, - Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), - Out: &bytes.Buffer{}, - ErrOut: &bytes.Buffer{}, - Reader: &seamReader{lines: []string{"solve"}}, - Catalog: catalog, - Context: ctx, - Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { return environment, nil }), - } - - _, err := session.Run() - if !errors.Is(err, context.Canceled) { - t.Fatalf("Run() error = %v, want context cancellation", err) - } - if environment.closeCount != 1 { - t.Fatalf("Close() calls = %d, want 1", environment.closeCount) - } -} - -func TestCompletionCleanupFailurePreventsXPAndIsRetriedByDefer(t *testing.T) { - catalog, item := seamCatalogMission(t, "1") - closeFailure := errors.New("cleanup failed") - closeAttempts := 0 - environment := &seamEnvironment{ - prompt: "/test", - execute: func(context.Context, string) (Execution, error) { - return Execution{Output: "/home/operator\n"}, nil - }, - close: func() error { - closeAttempts++ - if closeAttempts == 1 { - return closeFailure - } - return nil - }, - } - player := profile.New("tester") - session := Session{ - Mission: item, - Player: &player, - Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), - Out: &bytes.Buffer{}, - ErrOut: &bytes.Buffer{}, - Reader: &seamReader{lines: []string{"solve"}}, - Catalog: catalog, - Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { return environment, nil }), - } - - _, err := session.Run() - if !errors.Is(err, closeFailure) { - t.Fatalf("Run() error = %v, want cleanup failure", err) - } - if player.XP != 0 || player.IsComplete(item.ID) { - t.Fatalf("cleanup failure awarded completion: XP = %d, completed = %v", player.XP, player.IsComplete(item.ID)) - } - if environment.closeCount != 2 { - t.Fatalf("Close() calls = %d, want explicit attempt plus deferred retry", environment.closeCount) - } -} - -func TestDeferredCleanupFailureIsReturnedOnce(t *testing.T) { - catalog, item := seamCatalogMission(t, "1") - closeFailure := errors.New("cleanup failed") - environment := &seamEnvironment{prompt: "/test", close: func() error { return closeFailure }} - player := profile.New("tester") - session := Session{ - Mission: item, - Player: &player, - Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), - Out: &bytes.Buffer{}, - ErrOut: &bytes.Buffer{}, - Reader: &seamReader{lines: []string{"quit"}}, - Catalog: catalog, - Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { return environment, nil }), - } - - result, err := session.Run() - if !result.Quit || !errors.Is(err, closeFailure) { - t.Fatalf("result = %#v, error = %v", result, err) - } - if environment.closeCount != 1 { - t.Fatalf("Close() calls = %d, want 1", environment.closeCount) - } -} - func TestSandboxEnvironmentPreservesInteractiveVi(t *testing.T) { _, item := seamCatalogMission(t, "linux-config-surgery") environment, err := (SandboxFactory{}).Create(context.Background(), item) @@ -469,15 +76,6 @@ func TestSandboxEnvironmentPreservesInteractiveVi(t *testing.T) { } } -type unavailableFactory struct { - Factory - availability Availability -} - -func (f unavailableFactory) Availability(context.Context, mission.Mission) Availability { - return f.availability -} - func TestEnvironmentAvailabilityIsOptionalAndNonMutating(t *testing.T) { _, simulated := seamCatalogMission(t, "1") _, docker := seamCatalogMission(t, "docker-container-census") diff --git a/internal/game/fake_docker_env_test.go b/internal/game/fake_docker_env_test.go new file mode 100644 index 0000000..6cd7523 --- /dev/null +++ b/internal/game/fake_docker_env_test.go @@ -0,0 +1,302 @@ +package game + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func canonicalMissionEnvironment(item mission.Mission) (Environment, error) { + if item.EffectiveEnvironment() == mission.EnvironmentDocker { + return newMissionDockerEnvironment(item), nil + } + return (SandboxFactory{}).Create(context.Background(), item) +} + +type missionDockerEnvironment struct { + aliases []string + running map[string]bool + removed map[string]bool + logs map[string]string + exitCodes map[string]int + oneShot map[string]bool + crashLoop map[string]bool + health map[string]string + count int + networks map[string]bool + endpoints map[string]map[string]bool + disabled map[string]bool +} + +func newMissionDockerEnvironment(item mission.Mission) *missionDockerEnvironment { + environment := &missionDockerEnvironment{ + running: make(map[string]bool), + removed: make(map[string]bool), + logs: make(map[string]string), + exitCodes: make(map[string]int), + oneShot: make(map[string]bool), + crashLoop: make(map[string]bool), + health: make(map[string]string), + networks: make(map[string]bool), + endpoints: make(map[string]map[string]bool), + disabled: make(map[string]bool), + } + if item.Docker == nil { + return environment + } + for _, network := range item.Docker.Networks { + environment.networks[network.Name] = true + } + for _, container := range item.Docker.Containers { + environment.aliases = append(environment.aliases, container.Name) + environment.running[container.Name] = container.State == mission.DockerStateRunning + environment.logs[container.Name] = container.Log + environment.health[container.Name] = container.Health + environment.endpoints[container.Name] = make(map[string]bool) + for _, network := range container.Networks { + environment.endpoints[container.Name][network] = true + } + environment.disabled[container.Name] = len(container.Networks) == 0 + if container.ExitCode != nil { + environment.exitCodes[container.Name] = *container.ExitCode + environment.oneShot[container.Name] = true + } + if container.Restart == mission.DockerRestartOnFailure { + environment.crashLoop[container.Name] = true + // Setup waits for a visible loop before play begins. + environment.logs[container.Name] = strings.Repeat(container.Log+"\n", 3) + } + } + environment.count = len(item.Docker.Containers) + return environment +} + +func (e *missionDockerEnvironment) PromptLabel() string { return "docker" } + +func (e *missionDockerEnvironment) status(alias string) string { + switch { + case e.running[alias] && e.crashLoop[alias]: + return "restarting" + case e.running[alias] && e.health[alias] != "": + return "running (" + e.health[alias] + ")" + case e.running[alias]: + return "running" + default: + return "exited" + } +} + +func (e *missionDockerEnvironment) Execute(_ context.Context, line string) (Execution, error) { + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != "docker" { + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + fields = fields[1:] + if fields[0] == "network" { + return e.executeNetwork(line, fields[1:]) + } + if fields[0] == "container" { + fields = fields[1:] + } + result := Execution{PracticedCommands: []string{"docker"}, PipelineWidth: 1} + if len(fields) >= 1 && (fields[0] == "ps" || fields[0] == "ls") { + all := false + filters := make(map[string]string) + for index := 1; index < len(fields); index++ { + switch fields[index] { + case "-a", "--all": + all = true + case "-f", "--filter": + if index+1 >= len(fields) { + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + index++ + key, value, _ := strings.Cut(fields[index], "=") + filters[key] = value + default: + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + } + var output strings.Builder + for _, alias := range e.aliases { + if e.removed[alias] { + continue + } + if want, filtered := filters["status"]; filtered && !strings.HasPrefix(e.status(alias), want) { + continue + } + if want, filtered := filters["health"]; filtered && (!e.running[alias] || e.health[alias] != want) { + continue + } + if !all && filters["status"] == "" && !e.running[alias] { + continue + } + fmt.Fprintf(&output, "%s %s\n", alias, e.status(alias)) + } + result.Output = output.String() + return result, nil + } + tail := -1 + if len(fields) == 4 && fields[0] == "logs" && (fields[1] == "--tail" || fields[1] == "-n") { + parsed, err := strconv.Atoi(fields[2]) + if err != nil { + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + tail = parsed + fields = []string{"logs", fields[3]} + } + if len(fields) != 2 { + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + action, alias := fields[0], fields[1] + if _, exists := e.running[alias]; !exists { + return Execution{}, fmt.Errorf("unknown fake Docker alias %q", alias) + } + if e.removed[alias] { + return Execution{}, fmt.Errorf("docker: container %q has been removed", alias) + } + switch action { + case "start", "restart": + e.running[alias] = !e.oneShot[alias] || e.crashLoop[alias] + result.Output = alias + "\n" + case "stop": + e.running[alias] = false + result.Output = alias + "\n" + case "rm": + if e.running[alias] { + return Execution{}, fmt.Errorf("docker: cannot remove running container %q; stop it first", alias) + } + e.removed[alias] = true + e.count-- + result.Output = alias + "\n" + case "logs": + lines := strings.Split(strings.TrimSuffix(e.logs[alias], "\n"), "\n") + if tail >= 0 && tail < len(lines) { + lines = lines[len(lines)-tail:] + } + result.Output = strings.Join(lines, "\n") + "\n" + case "inspect": + result.Output = fmt.Sprintf("{\n \"Name\": %q,\n \"State\": {\n \"Running\": %t,\n \"Status\": %q,\n \"ExitCode\": %d\n }\n}\n", alias, e.running[alias], e.status(alias), e.exitCodes[alias]) + default: + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + return result, nil +} + +func (e *missionDockerEnvironment) executeNetwork(line string, fields []string) (Execution, error) { + result := Execution{PracticedCommands: []string{"docker"}, PipelineWidth: 1} + attached := func(network string) []string { + var aliases []string + for _, alias := range e.aliases { + if !e.removed[alias] && e.endpoints[alias][network] { + aliases = append(aliases, alias) + } + } + sort.Strings(aliases) + return aliases + } + switch { + case len(fields) == 1 && fields[0] == "ls": + var names []string + for name, exists := range e.networks { + if exists { + names = append(names, name) + } + } + sort.Strings(names) + result.Output = strings.Join(names, "\n") + "\n" + case len(fields) == 2 && fields[0] == "inspect": + if !e.networks[fields[1]] { + return Execution{}, fmt.Errorf("docker: network %q no longer exists", fields[1]) + } + encoded, err := json.MarshalIndent(struct { + Name string `json:"Name"` + Containers []string `json:"Containers"` + }{Name: fields[1], Containers: attached(fields[1])}, "", " ") + if err != nil { + return Execution{}, err + } + result.Output = string(encoded) + "\n" + case len(fields) == 2 && fields[0] == "create": + if e.networks[fields[1]] { + return Execution{}, fmt.Errorf("docker: network %q already exists", fields[1]) + } + e.networks[fields[1]] = true + result.Output = fields[1] + "\n" + case len(fields) == 2 && fields[0] == "rm": + if !e.networks[fields[1]] { + return Execution{}, fmt.Errorf("docker: network %q has already been removed", fields[1]) + } + if users := attached(fields[1]); len(users) > 0 { + return Execution{}, fmt.Errorf("docker: network %q still has containers attached (%s)", fields[1], strings.Join(users, ", ")) + } + e.networks[fields[1]] = false + result.Output = fields[1] + "\n" + case len(fields) == 3 && (fields[0] == "connect" || fields[0] == "disconnect"): + network, alias := fields[1], fields[2] + if !e.networks[network] { + return Execution{}, fmt.Errorf("docker: network %q has been removed", network) + } + if _, exists := e.running[alias]; !exists || e.removed[alias] { + return Execution{}, fmt.Errorf("docker: container %q is not available", alias) + } + if fields[0] == "connect" { + if e.disabled[alias] || e.endpoints[alias][network] { + return Execution{}, fmt.Errorf("docker: cannot connect %q to %q", alias, network) + } + e.endpoints[alias][network] = true + } else { + if !e.endpoints[alias][network] { + return Execution{}, fmt.Errorf("docker: container %q is not connected to network %q", alias, network) + } + delete(e.endpoints[alias], network) + } + default: + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + return result, nil +} + +func (e *missionDockerEnvironment) shareNetwork(first, second string) (shared, bothExist bool) { + for _, alias := range []string{first, second} { + if _, exists := e.running[alias]; !exists || e.removed[alias] { + return false, false + } + } + for network := range e.endpoints[first] { + if e.networks[network] && e.endpoints[second][network] { + return true, true + } + } + return false, true +} + +func (e *missionDockerEnvironment) Observe(_ context.Context, condition mission.Condition) (bool, error) { + switch condition.Type { + case mission.ConditionDockerNetworkShared, mission.ConditionDockerNetworkIsolated: + shared, bothExist := e.shareNetwork(condition.Containers[0], condition.Containers[1]) + return bothExist && shared == (condition.Type == mission.ConditionDockerNetworkShared), nil + case mission.ConditionDockerNetworkAbsent: + return !e.networks[condition.Network], nil + case mission.ConditionDockerContainerRunning: + return !e.removed[condition.Container] && e.running[condition.Container], nil + case mission.ConditionDockerContainerStopped: + _, exists := e.running[condition.Container] + return exists && !e.removed[condition.Container] && !e.running[condition.Container], nil + case mission.ConditionDockerContainerAbsent: + return e.removed[condition.Container], nil + case mission.ConditionDockerContainerCountEqual: + return condition.Count != nil && e.count == *condition.Count, nil + default: + return false, fmt.Errorf("unsupported fake Docker condition %q", condition.Type) + } +} + +func (e *missionDockerEnvironment) CompletionSource() CompletionSource { return nil } + +func (e *missionDockerEnvironment) Close() error { return nil } diff --git a/internal/game/input.go b/internal/game/input.go index 18706de..bb39cfd 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -6,40 +6,13 @@ import ( "fmt" "io" "os" - "sort" - "strings" - "unicode" - "unicode/utf8" - - "golang.org/x/term" "github.com/aleksandergregersen/opsquest/internal/sandbox" + "golang.org/x/term" ) const maxScriptedCommandBytes = 64 * 1024 -const terminalKeyDeleteForward rune = '\ue000' - -var terminalSequenceReplacements = map[string]string{ - "\x1bOH": "\x1b[H", // application-mode Home - "\x1bOF": "\x1b[F", // application-mode End - "\x1b[1~": "\x1b[H", // alternate Home - "\x1b[4~": "\x1b[F", // alternate End - "\x1b[7~": "\x1b[H", // rxvt Home - "\x1b[8~": "\x1b[F", // rxvt End - "\x1bb": "\x1b[1;3D", // Meta/Option-B - "\x1bf": "\x1b[1;3C", // Meta/Option-F - "\x1b[1;5D": "\x1b[1;3D", // Ctrl-Left - "\x1b[1;5C": "\x1b[1;3C", // Ctrl-Right - "\x1b[1;9D": "\x1b[H", // Command/Meta-Left - "\x1b[1;9C": "\x1b[F", // Command/Meta-Right - "\x1b[1;10D": "\x1b[H", // Shift-Command/Meta-Left - "\x1b[1;10C": "\x1b[F", // Shift-Command/Meta-Right - "\x1b[3~": string(terminalKeyDeleteForward), - "\x1b\x7f": "\x17", // Option-Backspace to Ctrl-W - "\x1b\x08": "\x17", // alternate Option-Backspace -} - const ( bracketedPasteStart = "\x1b[200~" bracketedPasteEnd = "\x1b[201~" @@ -81,122 +54,6 @@ func (r *scannerLineReader) Edit(_ sandbox.EditorRequest, _ viSaveFunc) error { return ErrInteractiveEditor } -type terminalReadWriter struct { - reader io.Reader - writer io.Writer -} - -// terminalKeyReader normalizes common terminal-specific key encodings into -// the VT100/readline subset understood by x/term. It never interprets bytes -// inside bracketed paste markers. -type terminalKeyReader struct { - source *bufio.Reader - pending []byte - deferredErr error - pasteActive bool - modalEscape bool -} - -func newTerminalKeyReader(reader io.Reader) *terminalKeyReader { - return &terminalKeyReader{source: bufio.NewReader(reader)} -} - -func (r *terminalKeyReader) Read(buffer []byte) (int, error) { - if len(buffer) == 0 { - return 0, nil - } - if len(r.pending) == 0 { - if r.deferredErr != nil { - err := r.deferredErr - r.deferredErr = nil - return 0, err - } - sequence, err := r.readSequence() - if len(sequence) == 0 { - return 0, err - } - if err != nil { - r.deferredErr = err - } - r.pending = r.normalize(sequence) - } - - count := copy(buffer, r.pending) - r.pending = r.pending[count:] - return count, nil -} - -func (r *terminalKeyReader) readSequence() ([]byte, error) { - first, err := r.source.ReadByte() - if err != nil { - return nil, err - } - sequence := []byte{first} - if first != '\x1b' { - return sequence, nil - } - // A standalone Escape key is meaningful to modal editors. ReadByte fills - // the source buffer with bytes already delivered by the terminal, so a - // complete arrow/CSI sequence remains available here while a lone Escape - // can return immediately instead of waiting for the next keypress. - if r.modalEscape && r.source.Buffered() == 0 { - return sequence, nil - } - - second, err := r.source.ReadByte() - if err != nil { - return sequence, err - } - sequence = append(sequence, second) - if second != '[' && second != 'O' { - return sequence, nil - } - - for len(sequence) < 32 { - next, readErr := r.source.ReadByte() - if readErr != nil { - return sequence, readErr - } - sequence = append(sequence, next) - if next >= 0x40 && next <= 0x7e { - break - } - } - return sequence, nil -} - -func (r *terminalKeyReader) buffered() int { return len(r.pending) + r.source.Buffered() } - -func (r *terminalKeyReader) unread(data []byte) { - pending := make([]byte, 0, len(data)+len(r.pending)) - pending = append(pending, data...) - pending = append(pending, r.pending...) - r.pending = pending -} - -func (r *terminalKeyReader) normalize(sequence []byte) []byte { - value := string(sequence) - if value == bracketedPasteStart { - r.pasteActive = true - return sequence - } - if value == bracketedPasteEnd && r.pasteActive { - r.pasteActive = false - return sequence - } - if r.pasteActive { - return sequence - } - if replacement, exists := terminalSequenceReplacements[value]; exists { - return []byte(replacement) - } - return sequence -} - -func (rw terminalReadWriter) Read(buffer []byte) (int, error) { return rw.reader.Read(buffer) } - -func (rw terminalReadWriter) Write(buffer []byte) (int, error) { return rw.writer.Write(buffer) } - type terminalLineReader struct { editor *term.Terminal keys *terminalKeyReader @@ -290,220 +147,3 @@ func (r *terminalLineReader) Edit(request sandbox.EditorRequest, save viSaveFunc } return runViEditor(r.keys, r.out, request, save, size) } - -func terminalCompleter(completions CompletionSource) func(string, int, rune) (string, int, bool) { - return func(line string, position int, key rune) (string, int, bool) { - switch key { - case terminalKeyDeleteForward: - return deleteRuneAtCursor(line, position) - case '\t': - return completeLine(line, position, completions) - default: - return line, position, false - } - } -} - -func deleteRuneAtCursor(line string, position int) (string, int, bool) { - if invalidCursorPosition(line, position) { - return line, position, true - } - if position == len(line) { - return line, position, true - } - _, size := utf8.DecodeRuneInString(line[position:]) - return line[:position] + line[position+size:], position, true -} - -func completeLine(line string, position int, completions CompletionSource) (string, int, bool) { - if invalidCursorPosition(line, position) { - return line, position, true - } - - start, end := completionTokenRange(line, position) - typed, quote, valid := completionToken(line[start:position]) - if !valid { - return line, position, true - } - - var candidates []CompletionCandidate - if commandPosition(line[:start]) && !scriptPathCompletion(typed) { - candidates = commandCandidates(completions, typed) - } else if completions != nil { - candidates = completions.PathCandidates(typed) - } - if len(candidates) == 0 { - return line, position, true - } - - replacement := "" - if len(candidates) == 1 { - candidate := candidates[0] - replacement = formatCompletion(candidate.Value, quote, !candidate.Directory) - if !candidate.Directory && completionNeedsSpace(line[end:]) { - replacement += " " - } - } else { - common := longestCommonPrefix(candidates) - if common == typed { - return line, position, true - } - replacement = formatCompletion(common, quote, false) - } - - completed := line[:start] + replacement + line[end:] - return completed, start + len(replacement), true -} - -func invalidCursorPosition(line string, position int) bool { - return position < 0 || position > len(line) || !utf8.ValidString(line) || position < len(line) && !utf8.RuneStart(line[position]) -} - -func scriptPathCompletion(value string) bool { - return strings.Contains(value, "/") || strings.HasPrefix(value, ".") || strings.HasPrefix(value, "~") -} - -func completionTokenRange(line string, position int) (int, int) { - start, end := 0, len(line) - var quote byte - escaped := false - for index := 0; index < len(line); index++ { - char := line[index] - if escaped { - escaped = false - continue - } - if char == '\\' && quote != '\'' { - escaped = true - continue - } - if quote != 0 { - if char == quote { - quote = 0 - } - continue - } - if char == '\'' || char == '"' { - quote = char - continue - } - if completionBoundary(char) { - if index < position { - start = index + 1 - continue - } - end = index - break - } - } - return start, end -} - -func completionBoundary(char byte) bool { - return strings.ContainsRune(" \t\r\n|<>", rune(char)) -} - -func completionToken(raw string) (string, byte, bool) { - var quote byte - if raw != "" && (raw[0] == '\'' || raw[0] == '"') { - quote = raw[0] - raw = raw[1:] - if strings.ContainsRune(raw, rune(quote)) { - return "", 0, false - } - } - if quote == '\'' { - return raw, quote, true - } - - var value strings.Builder - escaped := false - for _, char := range raw { - if escaped { - value.WriteRune(char) - escaped = false - continue - } - if char == '\\' { - escaped = true - continue - } - value.WriteRune(char) - } - if escaped { - return "", 0, false - } - return value.String(), quote, true -} - -func commandPosition(beforeToken string) bool { - trimmed := strings.TrimSpace(beforeToken) - return trimmed == "" || strings.HasSuffix(trimmed, "|") -} - -func commandCandidates(completions CompletionSource, prefix string) []CompletionCandidate { - commands := make([]string, 0) - if completions != nil { - commands = append(commands, completions.CommandNames()...) - } - commands = append(commands, "?", ":q", "exit", "guide", "hint", "list", "map", "missions", "next", "objective", "opsquest", "play", "prev", "previous", "quit", "restart", "status", "world", "worlds") - sort.Strings(commands) - candidates := make([]CompletionCandidate, 0) - last := "" - for _, command := range commands { - if command == last || !strings.HasPrefix(command, prefix) { - continue - } - candidates = append(candidates, CompletionCandidate{Value: command}) - last = command - } - return candidates -} - -func longestCommonPrefix(candidates []CompletionCandidate) string { - prefix := []rune(candidates[0].Value) - for _, candidate := range candidates[1:] { - value := []rune(candidate.Value) - limit := len(prefix) - if len(value) < limit { - limit = len(value) - } - index := 0 - for index < limit && prefix[index] == value[index] { - index++ - } - prefix = prefix[:index] - } - return string(prefix) -} - -func formatCompletion(value string, quote byte, closeQuote bool) string { - if quote == '\'' { - result := "'" + strings.ReplaceAll(value, "'", `'\''`) - if closeQuote { - result += "'" - } - return result - } - if quote == '"' { - replacer := strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`) - result := `"` + replacer.Replace(value) - if closeQuote { - result += `"` - } - return result - } - - var escaped strings.Builder - for _, char := range value { - if unicode.IsSpace(char) || strings.ContainsRune("\\'\"|<>#$*?[", char) { - escaped.WriteRune('\\') - } - escaped.WriteRune(char) - } - return escaped.String() -} - -func completionNeedsSpace(afterToken string) bool { - return afterToken == "" || strings.ContainsRune("|<>", rune(afterToken[0])) -} diff --git a/internal/game/line_completion.go b/internal/game/line_completion.go new file mode 100644 index 0000000..1438354 --- /dev/null +++ b/internal/game/line_completion.go @@ -0,0 +1,225 @@ +package game + +import ( + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +func terminalCompleter(completions CompletionSource) func(string, int, rune) (string, int, bool) { + return func(line string, position int, key rune) (string, int, bool) { + switch key { + case terminalKeyDeleteForward: + return deleteRuneAtCursor(line, position) + case '\t': + return completeLine(line, position, completions) + default: + return line, position, false + } + } +} + +func deleteRuneAtCursor(line string, position int) (string, int, bool) { + if invalidCursorPosition(line, position) { + return line, position, true + } + if position == len(line) { + return line, position, true + } + _, size := utf8.DecodeRuneInString(line[position:]) + return line[:position] + line[position+size:], position, true +} + +func completeLine(line string, position int, completions CompletionSource) (string, int, bool) { + if invalidCursorPosition(line, position) { + return line, position, true + } + + start, end := completionTokenRange(line, position) + typed, quote, valid := completionToken(line[start:position]) + if !valid { + return line, position, true + } + + var candidates []CompletionCandidate + if commandPosition(line[:start]) && !scriptPathCompletion(typed) { + candidates = commandCandidates(completions, typed) + } else if completions != nil { + candidates = completions.PathCandidates(typed) + } + if len(candidates) == 0 { + return line, position, true + } + + replacement := "" + if len(candidates) == 1 { + candidate := candidates[0] + replacement = formatCompletion(candidate.Value, quote, !candidate.Directory) + if !candidate.Directory && completionNeedsSpace(line[end:]) { + replacement += " " + } + } else { + common := longestCommonPrefix(candidates) + if common == typed { + return line, position, true + } + replacement = formatCompletion(common, quote, false) + } + + completed := line[:start] + replacement + line[end:] + return completed, start + len(replacement), true +} + +func invalidCursorPosition(line string, position int) bool { + return position < 0 || position > len(line) || !utf8.ValidString(line) || position < len(line) && !utf8.RuneStart(line[position]) +} + +func scriptPathCompletion(value string) bool { + return strings.Contains(value, "/") || strings.HasPrefix(value, ".") || strings.HasPrefix(value, "~") +} + +func completionTokenRange(line string, position int) (int, int) { + start, end := 0, len(line) + var quote byte + escaped := false + for index := 0; index < len(line); index++ { + char := line[index] + if escaped { + escaped = false + continue + } + if char == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if char == quote { + quote = 0 + } + continue + } + if char == '\'' || char == '"' { + quote = char + continue + } + if completionBoundary(char) { + if index < position { + start = index + 1 + continue + } + end = index + break + } + } + return start, end +} + +func completionBoundary(char byte) bool { + return strings.ContainsRune(" \t\r\n|<>", rune(char)) +} + +func completionToken(raw string) (string, byte, bool) { + var quote byte + if raw != "" && (raw[0] == '\'' || raw[0] == '"') { + quote = raw[0] + raw = raw[1:] + if strings.ContainsRune(raw, rune(quote)) { + return "", 0, false + } + } + if quote == '\'' { + return raw, quote, true + } + + var value strings.Builder + escaped := false + for _, char := range raw { + if escaped { + value.WriteRune(char) + escaped = false + continue + } + if char == '\\' { + escaped = true + continue + } + value.WriteRune(char) + } + if escaped { + return "", 0, false + } + return value.String(), quote, true +} + +func commandPosition(beforeToken string) bool { + trimmed := strings.TrimSpace(beforeToken) + return trimmed == "" || strings.HasSuffix(trimmed, "|") +} + +func commandCandidates(completions CompletionSource, prefix string) []CompletionCandidate { + commands := make([]string, 0) + if completions != nil { + commands = append(commands, completions.CommandNames()...) + } + commands = append(commands, "?", ":q", "exit", "guide", "hint", "list", "map", "missions", "next", "objective", "opsquest", "play", "prev", "previous", "quit", "restart", "status", "world", "worlds") + sort.Strings(commands) + candidates := make([]CompletionCandidate, 0) + last := "" + for _, command := range commands { + if command == last || !strings.HasPrefix(command, prefix) { + continue + } + candidates = append(candidates, CompletionCandidate{Value: command}) + last = command + } + return candidates +} + +func longestCommonPrefix(candidates []CompletionCandidate) string { + prefix := []rune(candidates[0].Value) + for _, candidate := range candidates[1:] { + value := []rune(candidate.Value) + limit := len(prefix) + if len(value) < limit { + limit = len(value) + } + index := 0 + for index < limit && prefix[index] == value[index] { + index++ + } + prefix = prefix[:index] + } + return string(prefix) +} + +func formatCompletion(value string, quote byte, closeQuote bool) string { + if quote == '\'' { + result := "'" + strings.ReplaceAll(value, "'", `'\''`) + if closeQuote { + result += "'" + } + return result + } + if quote == '"' { + replacer := strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`) + result := `"` + replacer.Replace(value) + if closeQuote { + result += `"` + } + return result + } + + var escaped strings.Builder + for _, char := range value { + if unicode.IsSpace(char) || strings.ContainsRune("\\'\"|<>#$*?[", char) { + escaped.WriteRune('\\') + } + escaped.WriteRune(char) + } + return escaped.String() +} + +func completionNeedsSpace(afterToken string) bool { + return afterToken == "" || strings.ContainsRune("|<>", rune(afterToken[0])) +} diff --git a/internal/game/linux_missions_test.go b/internal/game/linux_missions_test.go new file mode 100644 index 0000000..e354faf --- /dev/null +++ b/internal/game/linux_missions_test.go @@ -0,0 +1,237 @@ +package game + +import ( + "testing" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/sandbox" +) + +func TestNewLinuxMissionsAcceptAlternativesAndRejectIncompleteOutcomes(t *testing.T) { + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + tests := []struct { + id string + alternative []string + incomplete []string + }{ + {id: "linux-read-handoff", alternative: []string{"less handoff.txt"}, incomplete: []string{"cat handoff.old"}}, + {id: "linux-log-preview", alternative: []string{"head -3 /var/log/ledger/startup.log"}, incomplete: []string{"tail -n 3 startup.log"}}, + {id: "linux-error-headcount", alternative: []string{"grep -c ERROR deploy.log"}, incomplete: []string{"grep -c WARN deploy.log"}}, + {id: "linux-runbook-runner", alternative: []string{"chmod 750 publish-health.sh", "./publish-health.sh"}, incomplete: []string{"cat publish-health.sh"}}, + } + for _, test := range tests { + item, found := catalog.Find(test.id) + if !found { + t.Fatalf("mission %q not found", test.id) + } + run := func(t *testing.T, commands []string) bool { + t.Helper() + box, err := sandbox.New(item.Setup, item.StartDir) + if err != nil { + t.Fatal(err) + } + lastOutput := "" + for _, command := range commands { + result, err := box.Execute(command) + if err != nil { + t.Fatalf("Execute(%q) error = %v", command, err) + } + lastOutput = result.Output + } + complete, err := Validate(item.Validation, box, lastOutput) + if err != nil { + t.Fatal(err) + } + return complete + } + t.Run(test.id+" alternative", func(t *testing.T) { + if !run(t, test.alternative) { + t.Fatal("alternative observable outcome did not complete mission") + } + }) + t.Run(test.id+" incomplete", func(t *testing.T) { + if run(t, test.incomplete) { + t.Fatal("incomplete observable outcome completed mission") + } + }) + } +} + +func TestSearchMissionRejectsUnfilteredFindOutput(t *testing.T) { + catalog, _ := mission.LoadCatalog() + item, _ := catalog.Find("linux-find-logs") + box, err := sandbox.New(item.Setup, item.StartDir) + if err != nil { + t.Fatal(err) + } + result, err := box.Execute(`find . -name "*.log"`) + if err != nil { + t.Fatal(err) + } + complete, err := Validate(item.Validation, box, result.Output) + if err != nil { + t.Fatal(err) + } + if complete { + t.Fatalf("unfiltered output unexpectedly completed mission: %q", result.Output) + } +} + +func TestRebalancedMissionOutcomesRemainRouteIndependent(t *testing.T) { + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + + run := func(t *testing.T, missionID string, commands ...string) (mission.Mission, *sandbox.Sandbox, string) { + t.Helper() + item, found := catalog.Find(missionID) + if !found { + t.Fatalf("mission %q not found", missionID) + } + box, err := sandbox.New(item.Setup, item.StartDir) + if err != nil { + t.Fatal(err) + } + lastOutput := "" + for _, command := range commands { + result, err := box.Execute(command) + if err != nil { + t.Fatalf("Execute(%q) error = %v", command, err) + } + lastOutput = result.Output + } + return item, box, lastOutput + } + assertComplete := func(t *testing.T, item mission.Mission, box *sandbox.Sandbox, output string, want bool) { + t.Helper() + complete, err := Validate(item.Validation, box, output) + if err != nil { + t.Fatal(err) + } + if complete != want { + t.Fatalf("mission complete = %v, want %v", complete, want) + } + } + + t.Run("search accepts an explicit log-file grep route", func(t *testing.T) { + item, box, output := run(t, "linux-find-logs", `grep -l ERROR api/*.log worker/*.log assets/*.log`) + assertComplete(t, item, box, output, true) + }) + + t.Run("release move is complete", func(t *testing.T) { + item, box, output := run(t, "linux-release-shuffle", "mv incident-104.txt /archive/2026/incident-104.txt") + assertComplete(t, item, box, output, true) + }) + + t.Run("release copy without source removal is incomplete", func(t *testing.T) { + item, box, output := run(t, "linux-release-shuffle", "cp incident-104.txt /archive/2026/incident-104.txt") + assertComplete(t, item, box, output, false) + }) + + t.Run("pipeline accepts cut and sort unique", func(t *testing.T) { + item, box, output := run(t, "linux-pipeline-report", `grep ERROR incidents.log | cut -d " " -f 3 | sort -u > /reports/error-services.txt`) + assertComplete(t, item, box, output, true) + }) + + t.Run("pipeline without deduplication is incomplete", func(t *testing.T) { + item, box, output := run(t, "linux-pipeline-report", `grep ERROR incidents.log | awk '{print $3}' | sort > /reports/error-services.txt`) + assertComplete(t, item, box, output, false) + }) + + t.Run("production boss accepts an alternative route", func(t *testing.T) { + item, box, output := run(t, "linux-production-friday", + "tar -xf release.tar -C /deploy", + `printf 'SERVICE=checkout\nLOG_LEVEL=info\n' > /deploy/app/app.env`, + "chmod 0750 /deploy/app/deploy.sh", + "kill -TERM 9001", + ) + assertComplete(t, item, box, output, true) + }) + + t.Run("production boss rejects collateral process damage", func(t *testing.T) { + item, box, output := run(t, "linux-production-friday", + "tar -xf release.tar -C /deploy", + `sed -i 's/LOG_LEVEL=debug/LOG_LEVEL=info/' /deploy/app/app.env`, + "chmod 750 /deploy/app/deploy.sh", + "kill 9001 9002", + ) + assertComplete(t, item, box, output, false) + }) +} + +func TestScriptMissionsAcceptAlternativesAndRejectIncompleteOutcomes(t *testing.T) { + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + + run := func(t *testing.T, missionID string, commands ...string) (mission.Mission, *sandbox.Sandbox) { + t.Helper() + item, found := catalog.Find(missionID) + if !found { + t.Fatalf("mission %q not found", missionID) + } + box, err := sandbox.New(item.Setup, item.StartDir) + if err != nil { + t.Fatal(err) + } + for _, command := range commands { + if _, err := box.Execute(command); err != nil { + t.Fatalf("Execute(%q) error = %v", command, err) + } + } + return item, box + } + assertComplete := func(t *testing.T, item mission.Mission, box *sandbox.Sandbox, want bool) { + t.Helper() + complete, err := Validate(item.Validation, box, "") + if err != nil { + t.Fatal(err) + } + if complete != want { + t.Fatalf("mission complete = %v, want %v", complete, want) + } + } + + t.Run("different pipeline and sh execution are accepted", func(t *testing.T) { + item, box := run(t, "linux-report-on-repeat", + `printf '#!/bin/sh\ngrep ERROR /workspace/incidents.log | cut -d " " -f 3 | sort -u > /reports/error-services.txt\n' > error-report.sh`, + "chmod 750 error-report.sh", + "sh error-report.sh", + ) + assertComplete(t, item, box, true) + }) + + t.Run("repair without execution is incomplete", func(t *testing.T) { + item, box := run(t, "linux-report-on-repeat", + `sed -i 's/grep WARN/grep ERROR/' error-report.sh`, + "chmod 750 error-report.sh", + ) + assertComplete(t, item, box, false) + }) + + t.Run("manual boss commands leak caller state", func(t *testing.T) { + item, box := run(t, "linux-scope-creep", + `sed -i 's/grep INFO/grep ERROR/' night-shift.sh`, + "chmod 750 night-shift.sh", + "cd /srv/night-shift", + "export REPORT_LABEL=night-shift", + `grep ERROR events.log | awk '{print $3}' | sort | uniq > /reports/night-services.txt`, + `echo "$REPORT_LABEL" > /reports/run-label.txt`, + ) + assertComplete(t, item, box, false) + }) + + t.Run("sh preserves boss child scope", func(t *testing.T) { + item, box := run(t, "linux-scope-creep", + `sed -i 's/grep INFO/grep ERROR/' night-shift.sh`, + "chmod 750 night-shift.sh", + "sh night-shift.sh", + ) + assertComplete(t, item, box, true) + }) +} diff --git a/internal/game/mission_navigation.go b/internal/game/mission_navigation.go new file mode 100644 index 0000000..618c714 --- /dev/null +++ b/internal/game/mission_navigation.go @@ -0,0 +1,240 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + "unicode" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func missionNavigationFields(line string) ([]string, bool, error) { + fields, err := splitMissionNavigation(line) + if len(fields) == 0 { + return nil, false, nil + } + prefixed := fields[0] == "opsquest" + if prefixed { + fields = fields[1:] + if len(fields) == 0 { + if err != nil { + return nil, true, err + } + return []string{"missions"}, true, nil + } + } + switch fields[0] { + case "list", "missions", "map", "worlds", "world", "play", "next", "previous", "prev": + return fields, true, err + default: + if prefixed && err != nil { + return nil, true, err + } + // Non-navigation input belongs to the active environment, including its + // own quote and escape errors. + return nil, false, nil + } +} + +// splitMissionNavigation parses the small, host-independent argument syntax +// accepted by in-mission navigation. It deliberately performs no expansion or +// command execution: whitespace separates fields, quotes group text, and a +// backslash escapes the next character outside single quotes. +func splitMissionNavigation(line string) ([]string, error) { + fields := make([]string, 0) + var field strings.Builder + var quote rune + escaped := false + started := false + + flush := func() { + if !started { + return + } + fields = append(fields, field.String()) + field.Reset() + started = false + } + + for _, char := range line { + if escaped { + field.WriteRune(char) + escaped = false + started = true + continue + } + if quote != 0 { + if char == quote { + quote = 0 + continue + } + if quote == '"' && char == '\\' { + escaped = true + continue + } + field.WriteRune(char) + continue + } + + switch { + case unicode.IsSpace(char): + flush() + case char == '\'' || char == '"': + quote = char + started = true + case char == '\\': + escaped = true + started = true + default: + field.WriteRune(char) + started = true + } + } + + if escaped { + flush() + return fields, fmt.Errorf("unfinished escape") + } + if quote != 0 { + flush() + name := "single" + if quote == '"' { + name = "double" + } + return fields, fmt.Errorf("unterminated %s quote", name) + } + flush() + return fields, nil +} + +// navigate handles one in-lab navigation request. handled is false only for +// fields that are not a navigation command. +func (a *attempt) navigate(fields []string) (step, bool) { + switch fields[0] { + case "list", "missions", "map", "worlds": + a.listMissions(fields) + return step{}, true + case "world": + return a.switchWorld(fields), true + case "play": + return a.switchMission(fields), true + case "next", "previous", "prev": + return a.switchAdjacent(fields), true + default: + return step{}, false + } +} + +func (a *attempt) listMissions(fields []string) { + if a.ListMissions == nil { + a.fail("mission listing is unavailable in this session") + return + } + listArgs := fields[1:] + if fields[0] == "map" || fields[0] == "worlds" { + if len(fields) != 1 { + a.fail(fields[0] + " does not accept arguments") + return + } + listArgs = nil + } + if err := a.ListMissions(listArgs); err != nil { + a.fail(err.Error()) + } +} + +// switchWorld starts the next incomplete stage of a world, or replays Stage 1 +// of a completed one, and keeps continuous play inside that world. +func (a *attempt) switchWorld(fields []string) step { + if len(fields) != 2 { + a.fail("usage inside a mission: world NUMBER") + return step{} + } + worldNumber, err := strconv.Atoi(fields[1]) + if err != nil || worldNumber < 1 { + a.fail("world number must be a positive integer; use map to see worlds") + return step{} + } + track := a.Mission.EffectiveTrack() + target, found := a.Catalog.NextInWorld(track, worldNumber, a.Player.IsComplete) + replayingCompletedWorld := false + if !found { + if world, exists := a.Catalog.World(track, worldNumber); exists && len(world.Missions) > 0 { + target, found = world.Missions[0], true + replayingCompletedWorld = true + } + } + if !found { + a.fail(fmt.Sprintf("world %d does not exist in the %s track; use map to see worlds", worldNumber, track)) + return step{} + } + if target.ID == a.Mission.ID { + a.worldRoute = worldNumber + message := fmt.Sprintf("World %d route selected; already playing the recommended stage.", worldNumber) + if replayingCompletedWorld { + message = fmt.Sprintf("World %d replay selected; already playing Stage 1.", worldNumber) + } + fmt.Fprintln(a.Out, a.Style.Accent(message)) + return step{} + } + if replayingCompletedWorld { + fmt.Fprintln(a.Out, a.Style.Accent(fmt.Sprintf("World %d is complete; replaying Stage 1.", worldNumber))) + } + printMissionSwitch(a.Out, target, a.Style) + return finish(SessionResult{SwitchMission: target.ID, WorldRoute: worldNumber, HintsUsed: a.hintsUsed}) +} + +// switchMission jumps to a stage of the current world by number, or to any +// mission by ID. +func (a *attempt) switchMission(fields []string) step { + if len(fields) != 2 { + a.fail("usage inside a mission: play STAGE_OR_ID") + return step{} + } + ref := fields[1] + target, found := mission.Mission{}, false + if stageNumber, err := strconv.Atoi(ref); err == nil { + placement, placed := a.Catalog.Placement(a.Mission.ID) + if placed { + world, worldFound := a.Catalog.World(placement.Track, placement.WorldNumber) + if worldFound && stageNumber >= 1 && stageNumber <= len(world.Missions) { + target, found = world.Missions[stageNumber-1], true + } + if !found { + a.fail(fmt.Sprintf("stage %q does not exist in World %d; choose a stage from 1 to %d or use map", ref, placement.WorldNumber, placement.StageTotal)) + return step{} + } + } + } else { + target, found = a.Catalog.Find(ref) + } + if !found { + a.fail(fmt.Sprintf("mission %q not found; use list --ids to see available mission IDs", ref)) + return step{} + } + if target.ID == a.Mission.ID { + fmt.Fprintln(a.Out, a.Style.Accent(fmt.Sprintf("Already playing Mission %02d: %s.", target.Number, target.Title))) + return step{} + } + printMissionSwitch(a.Out, target, a.Style) + return finish(SessionResult{SwitchMission: target.ID, HintsUsed: a.hintsUsed}) +} + +func (a *attempt) switchAdjacent(fields []string) step { + direction := 1 + if fields[0] != "next" { + direction = -1 + } + if len(fields) != 1 { + a.fail(fmt.Sprintf("%s does not accept arguments", fields[0])) + return step{} + } + target, found := a.Catalog.AdjacentInTrack(a.Mission.ID, direction) + if !found { + fmt.Fprintln(a.Out, a.Style.Warning(fmt.Sprintf("Mission %02d is already at this end of the catalog.", a.Mission.Number))) + return step{} + } + printMissionSwitch(a.Out, target, a.Style) + return finish(SessionResult{SwitchMission: target.ID, HintsUsed: a.hintsUsed}) +} diff --git a/internal/game/missions_test.go b/internal/game/missions_test.go index 2f9bf71..63c6d96 100644 --- a/internal/game/missions_test.go +++ b/internal/game/missions_test.go @@ -2,11 +2,6 @@ package game import ( "context" - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" "testing" "github.com/aleksandergregersen/opsquest/internal/mission" @@ -156,638 +151,3 @@ func TestEveryMissionSuggestsCommandsSupportedByItsEnvironment(t *testing.T) { } } } - -func canonicalMissionEnvironment(item mission.Mission) (Environment, error) { - if item.EffectiveEnvironment() == mission.EnvironmentDocker { - return newMissionDockerEnvironment(item), nil - } - return (SandboxFactory{}).Create(context.Background(), item) -} - -type missionDockerEnvironment struct { - aliases []string - running map[string]bool - removed map[string]bool - logs map[string]string - exitCodes map[string]int - oneShot map[string]bool - crashLoop map[string]bool - health map[string]string - count int - networks map[string]bool - endpoints map[string]map[string]bool - disabled map[string]bool -} - -func newMissionDockerEnvironment(item mission.Mission) *missionDockerEnvironment { - environment := &missionDockerEnvironment{ - running: make(map[string]bool), - removed: make(map[string]bool), - logs: make(map[string]string), - exitCodes: make(map[string]int), - oneShot: make(map[string]bool), - crashLoop: make(map[string]bool), - health: make(map[string]string), - networks: make(map[string]bool), - endpoints: make(map[string]map[string]bool), - disabled: make(map[string]bool), - } - if item.Docker == nil { - return environment - } - for _, network := range item.Docker.Networks { - environment.networks[network.Name] = true - } - for _, container := range item.Docker.Containers { - environment.aliases = append(environment.aliases, container.Name) - environment.running[container.Name] = container.State == mission.DockerStateRunning - environment.logs[container.Name] = container.Log - environment.health[container.Name] = container.Health - environment.endpoints[container.Name] = make(map[string]bool) - for _, network := range container.Networks { - environment.endpoints[container.Name][network] = true - } - environment.disabled[container.Name] = len(container.Networks) == 0 - if container.ExitCode != nil { - environment.exitCodes[container.Name] = *container.ExitCode - environment.oneShot[container.Name] = true - } - if container.Restart == mission.DockerRestartOnFailure { - environment.crashLoop[container.Name] = true - // Setup waits for a visible loop before play begins. - environment.logs[container.Name] = strings.Repeat(container.Log+"\n", 3) - } - } - environment.count = len(item.Docker.Containers) - return environment -} - -func (e *missionDockerEnvironment) PromptLabel() string { return "docker" } - -func (e *missionDockerEnvironment) status(alias string) string { - switch { - case e.running[alias] && e.crashLoop[alias]: - return "restarting" - case e.running[alias] && e.health[alias] != "": - return "running (" + e.health[alias] + ")" - case e.running[alias]: - return "running" - default: - return "exited" - } -} - -func (e *missionDockerEnvironment) Execute(_ context.Context, line string) (Execution, error) { - fields := strings.Fields(line) - if len(fields) < 2 || fields[0] != "docker" { - return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) - } - fields = fields[1:] - if fields[0] == "network" { - return e.executeNetwork(line, fields[1:]) - } - if fields[0] == "container" { - fields = fields[1:] - } - result := Execution{PracticedCommands: []string{"docker"}, PipelineWidth: 1} - if len(fields) >= 1 && (fields[0] == "ps" || fields[0] == "ls") { - all := false - filters := make(map[string]string) - for index := 1; index < len(fields); index++ { - switch fields[index] { - case "-a", "--all": - all = true - case "-f", "--filter": - if index+1 >= len(fields) { - return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) - } - index++ - key, value, _ := strings.Cut(fields[index], "=") - filters[key] = value - default: - return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) - } - } - var output strings.Builder - for _, alias := range e.aliases { - if e.removed[alias] { - continue - } - if want, filtered := filters["status"]; filtered && !strings.HasPrefix(e.status(alias), want) { - continue - } - if want, filtered := filters["health"]; filtered && (!e.running[alias] || e.health[alias] != want) { - continue - } - if !all && filters["status"] == "" && !e.running[alias] { - continue - } - fmt.Fprintf(&output, "%s %s\n", alias, e.status(alias)) - } - result.Output = output.String() - return result, nil - } - tail := -1 - if len(fields) == 4 && fields[0] == "logs" && (fields[1] == "--tail" || fields[1] == "-n") { - parsed, err := strconv.Atoi(fields[2]) - if err != nil { - return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) - } - tail = parsed - fields = []string{"logs", fields[3]} - } - if len(fields) != 2 { - return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) - } - action, alias := fields[0], fields[1] - if _, exists := e.running[alias]; !exists { - return Execution{}, fmt.Errorf("unknown fake Docker alias %q", alias) - } - if e.removed[alias] { - return Execution{}, fmt.Errorf("docker: container %q has been removed", alias) - } - switch action { - case "start", "restart": - e.running[alias] = !e.oneShot[alias] || e.crashLoop[alias] - result.Output = alias + "\n" - case "stop": - e.running[alias] = false - result.Output = alias + "\n" - case "rm": - if e.running[alias] { - return Execution{}, fmt.Errorf("docker: cannot remove running container %q; stop it first", alias) - } - e.removed[alias] = true - e.count-- - result.Output = alias + "\n" - case "logs": - lines := strings.Split(strings.TrimSuffix(e.logs[alias], "\n"), "\n") - if tail >= 0 && tail < len(lines) { - lines = lines[len(lines)-tail:] - } - result.Output = strings.Join(lines, "\n") + "\n" - case "inspect": - result.Output = fmt.Sprintf("{\n \"Name\": %q,\n \"State\": {\n \"Running\": %t,\n \"Status\": %q,\n \"ExitCode\": %d\n }\n}\n", alias, e.running[alias], e.status(alias), e.exitCodes[alias]) - default: - return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) - } - return result, nil -} - -func (e *missionDockerEnvironment) executeNetwork(line string, fields []string) (Execution, error) { - result := Execution{PracticedCommands: []string{"docker"}, PipelineWidth: 1} - attached := func(network string) []string { - var aliases []string - for _, alias := range e.aliases { - if !e.removed[alias] && e.endpoints[alias][network] { - aliases = append(aliases, alias) - } - } - sort.Strings(aliases) - return aliases - } - switch { - case len(fields) == 1 && fields[0] == "ls": - var names []string - for name, exists := range e.networks { - if exists { - names = append(names, name) - } - } - sort.Strings(names) - result.Output = strings.Join(names, "\n") + "\n" - case len(fields) == 2 && fields[0] == "inspect": - if !e.networks[fields[1]] { - return Execution{}, fmt.Errorf("docker: network %q no longer exists", fields[1]) - } - encoded, err := json.MarshalIndent(struct { - Name string `json:"Name"` - Containers []string `json:"Containers"` - }{Name: fields[1], Containers: attached(fields[1])}, "", " ") - if err != nil { - return Execution{}, err - } - result.Output = string(encoded) + "\n" - case len(fields) == 2 && fields[0] == "create": - if e.networks[fields[1]] { - return Execution{}, fmt.Errorf("docker: network %q already exists", fields[1]) - } - e.networks[fields[1]] = true - result.Output = fields[1] + "\n" - case len(fields) == 2 && fields[0] == "rm": - if !e.networks[fields[1]] { - return Execution{}, fmt.Errorf("docker: network %q has already been removed", fields[1]) - } - if users := attached(fields[1]); len(users) > 0 { - return Execution{}, fmt.Errorf("docker: network %q still has containers attached (%s)", fields[1], strings.Join(users, ", ")) - } - e.networks[fields[1]] = false - result.Output = fields[1] + "\n" - case len(fields) == 3 && (fields[0] == "connect" || fields[0] == "disconnect"): - network, alias := fields[1], fields[2] - if !e.networks[network] { - return Execution{}, fmt.Errorf("docker: network %q has been removed", network) - } - if _, exists := e.running[alias]; !exists || e.removed[alias] { - return Execution{}, fmt.Errorf("docker: container %q is not available", alias) - } - if fields[0] == "connect" { - if e.disabled[alias] || e.endpoints[alias][network] { - return Execution{}, fmt.Errorf("docker: cannot connect %q to %q", alias, network) - } - e.endpoints[alias][network] = true - } else { - if !e.endpoints[alias][network] { - return Execution{}, fmt.Errorf("docker: container %q is not connected to network %q", alias, network) - } - delete(e.endpoints[alias], network) - } - default: - return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) - } - return result, nil -} - -func (e *missionDockerEnvironment) shareNetwork(first, second string) (shared, bothExist bool) { - for _, alias := range []string{first, second} { - if _, exists := e.running[alias]; !exists || e.removed[alias] { - return false, false - } - } - for network := range e.endpoints[first] { - if e.networks[network] && e.endpoints[second][network] { - return true, true - } - } - return false, true -} - -func (e *missionDockerEnvironment) Observe(_ context.Context, condition mission.Condition) (bool, error) { - switch condition.Type { - case mission.ConditionDockerNetworkShared, mission.ConditionDockerNetworkIsolated: - shared, bothExist := e.shareNetwork(condition.Containers[0], condition.Containers[1]) - return bothExist && shared == (condition.Type == mission.ConditionDockerNetworkShared), nil - case mission.ConditionDockerNetworkAbsent: - return !e.networks[condition.Network], nil - case mission.ConditionDockerContainerRunning: - return !e.removed[condition.Container] && e.running[condition.Container], nil - case mission.ConditionDockerContainerStopped: - _, exists := e.running[condition.Container] - return exists && !e.removed[condition.Container] && !e.running[condition.Container], nil - case mission.ConditionDockerContainerAbsent: - return e.removed[condition.Container], nil - case mission.ConditionDockerContainerCountEqual: - return condition.Count != nil && e.count == *condition.Count, nil - default: - return false, fmt.Errorf("unsupported fake Docker condition %q", condition.Type) - } -} - -func (e *missionDockerEnvironment) CompletionSource() CompletionSource { return nil } -func (e *missionDockerEnvironment) Close() error { return nil } - -func TestContainerCensusAcceptsAlternativeAndRejectsIncompleteOutcomes(t *testing.T) { - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("docker-container-census") - - t.Run("alternative container syntax", func(t *testing.T) { - environment := newMissionDockerEnvironment(item) - for _, command := range []string{"docker container ls --all", "docker container start api"} { - if _, err := environment.Execute(context.Background(), command); err != nil { - t.Fatal(err) - } - } - outcomes, err := evaluateOutcomes(context.Background(), item.Validation, environment, "") - if err != nil || !allOutcomesSatisfied(outcomes) { - t.Fatalf("alternative outcome complete = %v, error = %v", allOutcomesSatisfied(outcomes), err) - } - }) - - t.Run("healthy container only is incomplete", func(t *testing.T) { - environment := newMissionDockerEnvironment(item) - _, _ = environment.Execute(context.Background(), "docker start metrics") - outcomes, err := evaluateOutcomes(context.Background(), item.Validation, environment, "") - if err != nil { - t.Fatal(err) - } - if allOutcomesSatisfied(outcomes) { - t.Fatal("starting only the already-healthy container completed the mission") - } - }) - - t.Run("replacement count is incomplete", func(t *testing.T) { - environment := newMissionDockerEnvironment(item) - environment.running["api"] = true - environment.count = 3 - outcomes, err := evaluateOutcomes(context.Background(), item.Validation, environment, "") - if err != nil { - t.Fatal(err) - } - if allOutcomesSatisfied(outcomes) { - t.Fatal("an extra replacement container completed the mission") - } - }) -} - -func TestNewDockerMissionsAcceptAlternativesAndRejectIncompleteOutcomes(t *testing.T) { - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - tests := []struct { - id string - alternative []string - incomplete []string - }{ - {id: "docker-last-broadcast", alternative: []string{"docker container logs checkout"}, incomplete: []string{"docker inspect checkout"}}, - {id: "docker-exit-code-detective", alternative: []string{"docker container inspect migrate"}, incomplete: []string{"docker inspect seed"}}, - {id: "docker-quiet-worker", alternative: []string{"docker container stop worker"}, incomplete: []string{"docker stop metrics"}}, - {id: "docker-recovery-pair", alternative: []string{"docker container start backend", "docker container start frontend"}, incomplete: []string{"docker start frontend"}}, - {id: "docker-shift-handoff", alternative: []string{"docker container stop retiring", "docker container start standby"}, incomplete: []string{"docker start standby"}}, - {id: "docker-janitor-duty", alternative: []string{"docker container rm report-weekly", "docker container rm backup-0915", "docker container rm backup-0914"}, incomplete: []string{"docker rm backup-0914", "docker rm backup-0915"}}, - {id: "docker-janitor-duty", alternative: []string{"docker rm backup-0914", "docker rm backup-0915", "docker rm report-weekly", "docker ps -a"}, incomplete: []string{"docker stop worker", "docker rm worker", "docker rm backup-0914", "docker rm backup-0915", "docker rm report-weekly"}}, - {id: "docker-tail-end", alternative: []string{"docker container logs -n 3 ledger"}, incomplete: []string{"docker logs ledger"}}, - {id: "docker-tail-end", alternative: []string{"docker logs ledger", "docker logs --tail 3 ledger"}, incomplete: []string{"docker logs --tail 4 ledger"}}, - {id: "docker-running-isnt-healthy", alternative: []string{"docker ps --filter health=unhealthy", "docker container start web-c", "docker container stop web-b"}, incomplete: []string{"docker stop web-a", "docker start web-c"}}, - {id: "docker-running-isnt-healthy", alternative: []string{"docker stop web-b", "docker start web-c", "docker ps"}, incomplete: []string{"docker stop web-b", "docker rm web-b", "docker start web-c"}}, - {id: "docker-crash-loop", alternative: []string{"docker inspect payments", "docker container stop payments", "docker logs --tail 1 payments"}, incomplete: []string{"docker logs payments"}}, - {id: "docker-crash-loop", alternative: []string{"docker stop payments", "docker ps -a", "docker container logs payments"}, incomplete: []string{"docker stop payments", "docker logs payments", "docker rm payments"}}, - {id: "docker-postmortem-triage", alternative: []string{"docker ps --filter status=exited", "docker rm sync-users", "docker container rm sync-orders", "docker ps --filter health=unhealthy", "docker container stop api-canary"}, incomplete: []string{"docker stop api-canary", "docker rm sync-orders", "docker rm sync-users", "docker rm sync-invoices"}}, - {id: "docker-postmortem-triage", alternative: []string{"docker stop api-canary", "docker rm sync-orders", "docker rm sync-users", "docker logs sync-invoices"}, incomplete: []string{"docker stop api-canary", "docker rm api-canary", "docker rm sync-orders", "docker rm sync-users"}}, - {id: "docker-network-census", alternative: []string{"docker network ls", "docker network inspect cache-net"}, incomplete: []string{"docker network inspect web-net"}}, - {id: "docker-network-census", alternative: []string{"docker network inspect db-net", "docker network inspect cache-net"}, incomplete: []string{"docker network inspect cache-net", "docker inspect api"}}, - {id: "docker-cant-reach-the-db", alternative: []string{"docker network create api-db", "docker network connect api-db api", "docker network connect api-db db"}, incomplete: []string{"docker network connect web-net db"}}, - {id: "docker-cant-reach-the-db", alternative: []string{"docker network connect db-net api", "docker network inspect db-net"}, incomplete: []string{"docker network disconnect web-net api", "docker network connect db-net api"}}, - {id: "docker-need-to-know", alternative: []string{"docker stop batch", "docker network disconnect payments-net batch", "docker start batch"}, incomplete: []string{"docker network disconnect payments-net ledger-db"}}, - {id: "docker-need-to-know", alternative: []string{"docker network inspect app-net", "docker network disconnect payments-net batch"}, incomplete: []string{"docker network disconnect app-net batch"}}, - {id: "docker-private-channel", alternative: []string{"docker network create edge-net", "docker network connect edge-net gateway", "docker network connect edge-net api", "docker network disconnect app-net gateway", "docker network disconnect app-net api"}, incomplete: []string{"docker network create metrics-net", "docker network connect metrics-net exporter", "docker network connect metrics-net collector"}}, - {id: "docker-private-channel", alternative: []string{"docker network create metrics-net", "docker network connect metrics-net collector", "docker network connect metrics-net exporter", "docker network disconnect app-net collector", "docker network disconnect app-net exporter"}, incomplete: []string{"docker network create metrics-net", "docker network connect metrics-net exporter", "docker network connect metrics-net collector", "docker network disconnect app-net exporter", "docker network disconnect app-net collector", "docker network disconnect app-net gateway"}}, - {id: "docker-segmentation", alternative: []string{"docker network create web-net", "docker network connect web-net web", "docker network connect web-net api", "docker network disconnect flat-net web", "docker network disconnect legacy-net old-cron", "docker network rm legacy-net", "docker rm old-cron"}, incomplete: []string{"docker network create db-net", "docker network connect db-net api", "docker network connect db-net db", "docker network disconnect flat-net db", "docker rm old-cron"}}, - {id: "docker-segmentation", alternative: []string{"docker network ls", "docker rm old-cron", "docker network rm legacy-net", "docker network create db-net", "docker network connect db-net db", "docker network connect db-net api", "docker network disconnect flat-net db"}, incomplete: []string{"docker network disconnect flat-net db", "docker rm old-cron", "docker network rm legacy-net"}}, - } - for _, test := range tests { - item, found := catalog.Find(test.id) - if !found { - t.Fatalf("mission %q not found", test.id) - } - run := func(t *testing.T, commands []string) bool { - t.Helper() - environment := newMissionDockerEnvironment(item) - lastOutput := "" - for _, command := range commands { - result, err := environment.Execute(context.Background(), command) - if err != nil { - t.Fatalf("Execute(%q) error = %v", command, err) - } - lastOutput = result.Output - } - outcomes, err := evaluateOutcomes(context.Background(), item.Validation, environment, lastOutput) - if err != nil { - t.Fatal(err) - } - return allOutcomesSatisfied(outcomes) - } - t.Run(test.id+" alternative", func(t *testing.T) { - if !run(t, test.alternative) { - t.Fatal("alternative observable outcome did not complete mission") - } - }) - t.Run(test.id+" incomplete", func(t *testing.T) { - if run(t, test.incomplete) { - t.Fatal("incomplete observable outcome completed mission") - } - }) - } -} - -func TestNewLinuxMissionsAcceptAlternativesAndRejectIncompleteOutcomes(t *testing.T) { - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - tests := []struct { - id string - alternative []string - incomplete []string - }{ - {id: "linux-read-handoff", alternative: []string{"less handoff.txt"}, incomplete: []string{"cat handoff.old"}}, - {id: "linux-log-preview", alternative: []string{"head -3 /var/log/ledger/startup.log"}, incomplete: []string{"tail -n 3 startup.log"}}, - {id: "linux-error-headcount", alternative: []string{"grep -c ERROR deploy.log"}, incomplete: []string{"grep -c WARN deploy.log"}}, - {id: "linux-runbook-runner", alternative: []string{"chmod 750 publish-health.sh", "./publish-health.sh"}, incomplete: []string{"cat publish-health.sh"}}, - } - for _, test := range tests { - item, found := catalog.Find(test.id) - if !found { - t.Fatalf("mission %q not found", test.id) - } - run := func(t *testing.T, commands []string) bool { - t.Helper() - box, err := sandbox.New(item.Setup, item.StartDir) - if err != nil { - t.Fatal(err) - } - lastOutput := "" - for _, command := range commands { - result, err := box.Execute(command) - if err != nil { - t.Fatalf("Execute(%q) error = %v", command, err) - } - lastOutput = result.Output - } - complete, err := Validate(item.Validation, box, lastOutput) - if err != nil { - t.Fatal(err) - } - return complete - } - t.Run(test.id+" alternative", func(t *testing.T) { - if !run(t, test.alternative) { - t.Fatal("alternative observable outcome did not complete mission") - } - }) - t.Run(test.id+" incomplete", func(t *testing.T) { - if run(t, test.incomplete) { - t.Fatal("incomplete observable outcome completed mission") - } - }) - } -} - -func TestSearchMissionRejectsUnfilteredFindOutput(t *testing.T) { - catalog, _ := mission.LoadCatalog() - item, _ := catalog.Find("linux-find-logs") - box, err := sandbox.New(item.Setup, item.StartDir) - if err != nil { - t.Fatal(err) - } - result, err := box.Execute(`find . -name "*.log"`) - if err != nil { - t.Fatal(err) - } - complete, err := Validate(item.Validation, box, result.Output) - if err != nil { - t.Fatal(err) - } - if complete { - t.Fatalf("unfiltered output unexpectedly completed mission: %q", result.Output) - } -} - -func TestRebalancedMissionOutcomesRemainRouteIndependent(t *testing.T) { - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - - run := func(t *testing.T, missionID string, commands ...string) (mission.Mission, *sandbox.Sandbox, string) { - t.Helper() - item, found := catalog.Find(missionID) - if !found { - t.Fatalf("mission %q not found", missionID) - } - box, err := sandbox.New(item.Setup, item.StartDir) - if err != nil { - t.Fatal(err) - } - lastOutput := "" - for _, command := range commands { - result, err := box.Execute(command) - if err != nil { - t.Fatalf("Execute(%q) error = %v", command, err) - } - lastOutput = result.Output - } - return item, box, lastOutput - } - assertComplete := func(t *testing.T, item mission.Mission, box *sandbox.Sandbox, output string, want bool) { - t.Helper() - complete, err := Validate(item.Validation, box, output) - if err != nil { - t.Fatal(err) - } - if complete != want { - t.Fatalf("mission complete = %v, want %v", complete, want) - } - } - - t.Run("search accepts an explicit log-file grep route", func(t *testing.T) { - item, box, output := run(t, "linux-find-logs", `grep -l ERROR api/*.log worker/*.log assets/*.log`) - assertComplete(t, item, box, output, true) - }) - - t.Run("release move is complete", func(t *testing.T) { - item, box, output := run(t, "linux-release-shuffle", "mv incident-104.txt /archive/2026/incident-104.txt") - assertComplete(t, item, box, output, true) - }) - - t.Run("release copy without source removal is incomplete", func(t *testing.T) { - item, box, output := run(t, "linux-release-shuffle", "cp incident-104.txt /archive/2026/incident-104.txt") - assertComplete(t, item, box, output, false) - }) - - t.Run("pipeline accepts cut and sort unique", func(t *testing.T) { - item, box, output := run(t, "linux-pipeline-report", `grep ERROR incidents.log | cut -d " " -f 3 | sort -u > /reports/error-services.txt`) - assertComplete(t, item, box, output, true) - }) - - t.Run("pipeline without deduplication is incomplete", func(t *testing.T) { - item, box, output := run(t, "linux-pipeline-report", `grep ERROR incidents.log | awk '{print $3}' | sort > /reports/error-services.txt`) - assertComplete(t, item, box, output, false) - }) - - t.Run("production boss accepts an alternative route", func(t *testing.T) { - item, box, output := run(t, "linux-production-friday", - "tar -xf release.tar -C /deploy", - `printf 'SERVICE=checkout\nLOG_LEVEL=info\n' > /deploy/app/app.env`, - "chmod 0750 /deploy/app/deploy.sh", - "kill -TERM 9001", - ) - assertComplete(t, item, box, output, true) - }) - - t.Run("production boss rejects collateral process damage", func(t *testing.T) { - item, box, output := run(t, "linux-production-friday", - "tar -xf release.tar -C /deploy", - `sed -i 's/LOG_LEVEL=debug/LOG_LEVEL=info/' /deploy/app/app.env`, - "chmod 750 /deploy/app/deploy.sh", - "kill 9001 9002", - ) - assertComplete(t, item, box, output, false) - }) -} - -func TestScriptMissionsAcceptAlternativesAndRejectIncompleteOutcomes(t *testing.T) { - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - - run := func(t *testing.T, missionID string, commands ...string) (mission.Mission, *sandbox.Sandbox) { - t.Helper() - item, found := catalog.Find(missionID) - if !found { - t.Fatalf("mission %q not found", missionID) - } - box, err := sandbox.New(item.Setup, item.StartDir) - if err != nil { - t.Fatal(err) - } - for _, command := range commands { - if _, err := box.Execute(command); err != nil { - t.Fatalf("Execute(%q) error = %v", command, err) - } - } - return item, box - } - assertComplete := func(t *testing.T, item mission.Mission, box *sandbox.Sandbox, want bool) { - t.Helper() - complete, err := Validate(item.Validation, box, "") - if err != nil { - t.Fatal(err) - } - if complete != want { - t.Fatalf("mission complete = %v, want %v", complete, want) - } - } - - t.Run("different pipeline and sh execution are accepted", func(t *testing.T) { - item, box := run(t, "linux-report-on-repeat", - `printf '#!/bin/sh\ngrep ERROR /workspace/incidents.log | cut -d " " -f 3 | sort -u > /reports/error-services.txt\n' > error-report.sh`, - "chmod 750 error-report.sh", - "sh error-report.sh", - ) - assertComplete(t, item, box, true) - }) - - t.Run("repair without execution is incomplete", func(t *testing.T) { - item, box := run(t, "linux-report-on-repeat", - `sed -i 's/grep WARN/grep ERROR/' error-report.sh`, - "chmod 750 error-report.sh", - ) - assertComplete(t, item, box, false) - }) - - t.Run("manual boss commands leak caller state", func(t *testing.T) { - item, box := run(t, "linux-scope-creep", - `sed -i 's/grep INFO/grep ERROR/' night-shift.sh`, - "chmod 750 night-shift.sh", - "cd /srv/night-shift", - "export REPORT_LABEL=night-shift", - `grep ERROR events.log | awk '{print $3}' | sort | uniq > /reports/night-services.txt`, - `echo "$REPORT_LABEL" > /reports/run-label.txt`, - ) - assertComplete(t, item, box, false) - }) - - t.Run("sh preserves boss child scope", func(t *testing.T) { - item, box := run(t, "linux-scope-creep", - `sed -i 's/grep INFO/grep ERROR/' night-shift.sh`, - "chmod 750 night-shift.sh", - "sh night-shift.sh", - ) - assertComplete(t, item, box, true) - }) -} diff --git a/internal/game/progression.go b/internal/game/progression.go index cc7cd9b..61dbdfe 100644 --- a/internal/game/progression.go +++ b/internal/game/progression.go @@ -67,3 +67,9 @@ func ReconcileCommandAchievements(player *profile.Profile, now time.Time) []prof } return []profile.Achievement{achievement} } + +// AdjustedReward returns the XP still available after hints while preserving +// the mission's minimum quarter-reward floor. +func AdjustedReward(item mission.Mission, hintsUsed int) int { + return max(item.Rewards.XP-hintsUsed*item.Rewards.HintPenalty, item.Rewards.XP/4) +} diff --git a/internal/game/seam_test.go b/internal/game/seam_test.go new file mode 100644 index 0000000..66acc1a --- /dev/null +++ b/internal/game/seam_test.go @@ -0,0 +1,115 @@ +package game + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/sandbox" +) + +type seamCompletionSource struct { + commands []string + paths map[string][]CompletionCandidate +} + +func (s *seamCompletionSource) CommandNames() []string { + return append([]string(nil), s.commands...) +} + +func (s *seamCompletionSource) PathCandidates(prefix string) []CompletionCandidate { + return append([]CompletionCandidate(nil), s.paths[prefix]...) +} + +type seamEnvironment struct { + prompt string + completions CompletionSource + execute func(context.Context, string) (Execution, error) + observe func(context.Context, mission.Condition) (bool, error) + close func() error + closeCount int +} + +func (e *seamEnvironment) PromptLabel() string { + return e.prompt +} + +func (e *seamEnvironment) Execute(ctx context.Context, line string) (Execution, error) { + if e.execute == nil { + return Execution{}, nil + } + return e.execute(ctx, line) +} + +func (e *seamEnvironment) Observe(ctx context.Context, condition mission.Condition) (bool, error) { + if e.observe == nil { + return false, fmt.Errorf("unexpected observation %s", condition.Type) + } + return e.observe(ctx, condition) +} + +func (e *seamEnvironment) CompletionSource() CompletionSource { + return e.completions +} + +func (e *seamEnvironment) Close() error { + e.closeCount++ + if e.close == nil { + return nil + } + return e.close() +} + +type seamReader struct { + lines []string + index int + endErr error + prompts []string + completions []CompletionSource + edit func(sandbox.EditorRequest, viSaveFunc) error +} + +func (r *seamReader) ReadLine(prompt string, completions CompletionSource) (string, error) { + r.prompts = append(r.prompts, prompt) + r.completions = append(r.completions, completions) + if r.index >= len(r.lines) { + if r.endErr != nil { + return "", r.endErr + } + return "", io.EOF + } + line := r.lines[r.index] + r.index++ + return line, nil +} + +func (r *seamReader) Edit(request sandbox.EditorRequest, save viSaveFunc) error { + if r.edit == nil { + return ErrInteractiveEditor + } + return r.edit(request, save) +} + +func seamCatalogMission(t *testing.T, ref string) (mission.Catalog, mission.Mission) { + t.Helper() + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, found := catalog.Find(ref) + if !found { + t.Fatalf("mission %s not found", ref) + } + return catalog, item +} + +type unavailableFactory struct { + Factory + availability Availability +} + +func (f unavailableFactory) Availability(context.Context, mission.Mission) Availability { + return f.availability +} diff --git a/internal/game/session.go b/internal/game/session.go index a990ab5..603df4a 100644 --- a/internal/game/session.go +++ b/internal/game/session.go @@ -5,11 +5,8 @@ import ( "errors" "fmt" "io" - "slices" - "strconv" "strings" "time" - "unicode" "github.com/aleksandergregersen/opsquest/internal/mission" "github.com/aleksandergregersen/opsquest/internal/profile" @@ -55,69 +52,28 @@ type SessionResult struct { } func (s Session) Run() (returnResult SessionResult, returnErr error) { - if s.Player == nil { - return SessionResult{}, fmt.Errorf("mission session requires a player profile") - } - if s.Saver == nil { - return SessionResult{}, fmt.Errorf("mission session requires profile persistence") - } - if s.In == nil { - s.In = strings.NewReader("") - } - if s.Out == nil { - s.Out = io.Discard - } - if s.ErrOut == nil { - s.ErrOut = io.Discard - } - ctx := defaultContext(s.Context) - factory := defaultFactory(s.Factory) - environment, err := createManagedEnvironment(ctx, factory, s.Mission) + a, err := s.begin() if err != nil { - return SessionResult{}, fmt.Errorf("prepare mission: %w", err) + return SessionResult{}, err } defer func() { - if err := environment.close(); err != nil { + if err := a.environment.close(); err != nil { returnErr = errors.Join(returnErr, fmt.Errorf("close mission environment: %w", err)) } }() - if s.Now == nil { - s.Now = time.Now + if err := a.announce(); err != nil { + return SessionResult{}, err } - - replaying := s.Player.IsComplete(s.Mission.ID) - hintsUsed := s.Player.MissionHints(s.Mission.ID) - currentOutcomes := []outcomeResult(nil) - if s.Reporter != nil { - currentOutcomes, err = evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, "") - if err != nil { - return SessionResult{}, fmt.Errorf("prepare companion mission status: %w", err) - } - s.reportAttempt(AttemptStarted, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, nil, nil, nil) - } - if s.Companion { - fmt.Fprintf(s.Out, "\nMission %02d ready in the web companion. Enter lab commands below.\n", s.Mission.Number) - } else { - printMission(s.Out, s.Mission, hintsUsed, replaying, s.Catalog, s.Style) - } - reader := s.Reader - if reader == nil { - reader = NewCommandLineReader(s.In, s.Out) - } - discovered := make([]string, 0) - practiced := make([]string, 0) - lastOutput := "" - worldRoute := 0 - + a.practiced = make([]string, 0) + a.discovered = make([]string, 0) for { - if err := ctx.Err(); err != nil { + if err := a.ctx.Err(); err != nil { return SessionResult{}, fmt.Errorf("mission context: %w", err) } - line, readErr := reader.ReadLine(s.Style.Prompt(environment.PromptLabel()), environment.CompletionSource()) + line, readErr := a.reader.ReadLine(a.Style.Prompt(a.environment.PromptLabel()), a.environment.CompletionSource()) if errors.Is(readErr, io.EOF) { - fmt.Fprintln(s.Out) - s.reportAttempt(AttemptPaused, AttemptStatePaused, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) - return SessionResult{Quit: true, HintsUsed: hintsUsed}, nil + fmt.Fprintln(a.Out) + return a.pause(), nil } if readErr != nil { return SessionResult{}, fmt.Errorf("read command: %w", readErr) @@ -126,567 +82,140 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { if line == "" { continue } - if fields, navigation, navigationErr := missionNavigationFields(line); navigation { - if navigationErr != nil { - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure("invalid mission navigation: "+navigationErr.Error())) - continue - } - switch fields[0] { - case "list", "missions", "map", "worlds": - if s.ListMissions == nil { - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure("mission listing is unavailable in this session")) - continue - } - listArgs := fields[1:] - if fields[0] == "map" || fields[0] == "worlds" { - if len(fields) != 1 { - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure(fields[0]+" does not accept arguments")) - continue - } - listArgs = nil - } - if err := s.ListMissions(listArgs); err != nil { - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure(err.Error())) - } - continue - case "world": - if len(fields) != 2 { - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure("usage inside a mission: world NUMBER")) - continue - } - worldNumber, err := strconv.Atoi(fields[1]) - if err != nil || worldNumber < 1 { - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure("world number must be a positive integer; use map to see worlds")) - continue - } - target, found := s.Catalog.NextInWorld(s.Mission.EffectiveTrack(), worldNumber, s.Player.IsComplete) - replayingCompletedWorld := false - if !found { - if world, exists := s.Catalog.World(s.Mission.EffectiveTrack(), worldNumber); exists && len(world.Missions) > 0 { - target, found = world.Missions[0], true - replayingCompletedWorld = true - } - } - if !found { - message := fmt.Sprintf("world %d does not exist in the %s track; use map to see worlds", worldNumber, s.Mission.EffectiveTrack()) - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure(message)) - continue - } - if target.ID == s.Mission.ID { - worldRoute = worldNumber - message := fmt.Sprintf("World %d route selected; already playing the recommended stage.", worldNumber) - if replayingCompletedWorld { - message = fmt.Sprintf("World %d replay selected; already playing Stage 1.", worldNumber) - } - fmt.Fprintln(s.Out, s.Style.Accent(message)) - continue - } - if replayingCompletedWorld { - fmt.Fprintln(s.Out, s.Style.Accent(fmt.Sprintf("World %d is complete; replaying Stage 1.", worldNumber))) - } - printMissionSwitch(s.Out, target, s.Style) - return SessionResult{SwitchMission: target.ID, WorldRoute: worldNumber, HintsUsed: hintsUsed}, nil - case "play": - if len(fields) != 2 { - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure("usage inside a mission: play STAGE_OR_ID")) - continue - } - ref := fields[1] - target, found := mission.Mission{}, false - if stageNumber, err := strconv.Atoi(ref); err == nil { - placement, placed := s.Catalog.Placement(s.Mission.ID) - if placed { - world, worldFound := s.Catalog.World(placement.Track, placement.WorldNumber) - if worldFound && stageNumber >= 1 && stageNumber <= len(world.Missions) { - target, found = world.Missions[stageNumber-1], true - } - if !found { - message := fmt.Sprintf("stage %q does not exist in World %d; choose a stage from 1 to %d or use map", ref, placement.WorldNumber, placement.StageTotal) - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure(message)) - continue - } - } - } else { - target, found = s.Catalog.Find(ref) - } - if !found { - message := fmt.Sprintf("mission %q not found; use list --ids to see available mission IDs", ref) - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure(message)) - continue - } - if target.ID == s.Mission.ID { - message := fmt.Sprintf("Already playing Mission %02d: %s.", target.Number, target.Title) - fmt.Fprintln(s.Out, s.Style.Accent(message)) - continue - } - printMissionSwitch(s.Out, target, s.Style) - return SessionResult{SwitchMission: target.ID, HintsUsed: hintsUsed}, nil - case "next", "previous", "prev": - direction := 1 - if fields[0] != "next" { - direction = -1 - } - if len(fields) != 1 { - message := fmt.Sprintf("%s does not accept arguments", fields[0]) - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure(message)) - continue - } - target, found := s.Catalog.AdjacentInTrack(s.Mission.ID, direction) - if !found { - message := fmt.Sprintf("Mission %02d is already at this end of the catalog.", s.Mission.Number) - fmt.Fprintln(s.Out, s.Style.Warning(message)) - continue - } - printMissionSwitch(s.Out, target, s.Style) - return SessionResult{SwitchMission: target.ID, HintsUsed: hintsUsed}, nil - } - } - - switch line { - case "quit", "exit", ":q": - fmt.Fprintln(s.Out, s.Style.Accent("Mission paused. Your profile progress is safe.")) - s.reportAttempt(AttemptPaused, AttemptStatePaused, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) - return SessionResult{Quit: true, HintsUsed: hintsUsed}, nil - case "hint": - if hintsUsed >= len(s.Mission.Hints) { - fmt.Fprintln(s.Out, s.Style.Warning("No more hints. ByteWorks has exhausted its documentation budget.")) - continue - } - cost := 0 - if replaying { - hintsUsed++ - } else { - before := AdjustedReward(s.Mission, hintsUsed) - hintsUsed = s.Player.RecordHint(s.Mission.ID) - if err := s.Saver.Save(*s.Player); err != nil { - return SessionResult{}, err - } - cost = before - AdjustedReward(s.Mission, hintsUsed) - } - costLabel := fmt.Sprintf("-%d XP", cost) - if cost == 0 { - costLabel = "no XP cost" - } - s.reportAttempt(AttemptHint, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) - if s.Companion { - message := fmt.Sprintf("Hint %d/%d revealed in the web companion (%s).", hintsUsed, len(s.Mission.Hints), costLabel) - fmt.Fprintln(s.Out, s.Style.Warning(message)) - } else { - prefix := fmt.Sprintf("Hint %d/%d (%s):", hintsUsed, len(s.Mission.Hints), costLabel) - fmt.Fprintf(s.Out, "%s %s\n", s.Style.Warning(prefix), s.Mission.Hints[hintsUsed-1]) - } - continue - case "objective": - if s.Companion { - fmt.Fprintln(s.Out, s.Style.Accent("The objective and suggested commands are shown in the web companion.")) - } else { - fmt.Fprintf(s.Out, "%s\n%s\n\n%s\n", s.Style.Section("OBJECTIVE"), s.Mission.Objective, s.Style.CommandGuide(s.Mission.SuggestedCommands)) - } - continue - case "status": - outcomes, err := evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, lastOutput) - if err != nil { - return SessionResult{}, fmt.Errorf("check mission status: %w", err) - } - currentOutcomes = outcomes - s.reportAttempt(AttemptProgress, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) - if s.Companion { - fmt.Fprintln(s.Out, s.Style.Accent("Mission status refreshed in the web companion.")) - } else { - printOutcomeStatus(s.Out, outcomes, s.Style) - } - continue - case "restart": - if err := environment.close(); err != nil { - return SessionResult{}, fmt.Errorf("restart mission: close current environment: %w", err) - } - environment, err = createManagedEnvironment(ctx, factory, s.Mission) - if err != nil { - return SessionResult{}, fmt.Errorf("restart mission: %w", err) - } - lastOutput = "" - if s.Reporter != nil { - currentOutcomes, err = evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, "") - if err != nil { - return SessionResult{}, fmt.Errorf("refresh companion mission status: %w", err) - } - s.reportAttempt(AttemptRestarted, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) - } - fmt.Fprintln(s.Out, s.Style.Accent("Mission environment restarted. Hints and command mastery are retained.")) - continue - case "?", "guide": - if s.Companion { - fmt.Fprintln(s.Out, s.Style.Accent("Mission controls and guidance are shown in the web companion.")) - } else { - printMissionControls(s.Out, s.Style) - } - continue - } - - result, executeErr := environment.Execute(ctx, line) - if executeErr != nil { - if err := ctx.Err(); err != nil { - return SessionResult{}, fmt.Errorf("execute command: %w", err) - } - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure(executeErr.Error())) - continue - } - if err := ctx.Err(); err != nil { - return SessionResult{}, fmt.Errorf("execute command: %w", err) - } - if result.Interactive != nil { - if result.Interactive.Run == nil { - return SessionResult{}, fmt.Errorf("run %s: interactive action has no runner", result.Interactive.Command) - } - if err := result.Interactive.Run(reader); err != nil { - if errors.Is(err, ErrInteractiveEditor) || errors.Is(err, ErrUnsupportedEditorFile) { - message := fmt.Sprintf("%s: %v", result.Interactive.Command, err) - fmt.Fprintln(s.ErrOut, s.ErrorStyle.Failure(message)) - continue - } - return SessionResult{}, fmt.Errorf("run %s: %w", result.Interactive.Command, err) - } - } - if result.Output != "" { - fmt.Fprint(s.Out, result.Output) - if !strings.HasSuffix(result.Output, "\n") { - fmt.Fprintln(s.Out) - } - } - lastOutput = result.Output - for _, command := range result.PracticedCommands { - if !slices.Contains(practiced, command) { - practiced = append(practiced, command) - } - if s.Player.Commands[command] == 0 && !slices.Contains(discovered, command) { - discovered = append(discovered, command) - } - } - s.Player.RecordCommands(result.PracticedCommands) - unlocked := make([]profile.Achievement, 0) - achievementTime := s.Now() - if result.PipelineWidth >= 3 { - if achievement, added := s.Player.UnlockAchievement(profile.AchievementPipeDream, achievementTime); added { - unlocked = append(unlocked, achievement) - } - } - unlocked = append(unlocked, ReconcileCommandAchievements(s.Player, achievementTime)...) - if err := s.Saver.Save(*s.Player); err != nil { - return SessionResult{}, err - } - - outcomes, err := evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, result.Output) + next, err := a.handle(line) if err != nil { - return SessionResult{}, fmt.Errorf("validate mission: %w", err) - } - currentOutcomes = outcomes - s.reportAttempt(AttemptProgress, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, unlocked) - if !allOutcomesSatisfied(outcomes) { - printAchievements(s.Out, unlocked, s.Style) - if !s.Companion { - message := fmt.Sprintf("Progress — %d/%d outcome checks satisfied. Type status to see what remains.", satisfiedOutcomeCount(outcomes), len(outcomes)) - fmt.Fprintln(s.Out, s.Style.Accent(message)) - } - continue - } - if err := environment.close(); err != nil { - return SessionResult{}, fmt.Errorf("complete mission: close environment before awarding XP: %w", err) - } - - xp := AdjustedReward(s.Mission, hintsUsed) - completedAt := s.Now() - firstCompletion := s.Player.Complete(s.Mission.ID, xp, hintsUsed, completedAt) - if !firstCompletion { - xp = 0 - } else { - unlocked = append(unlocked, ReconcileAchievements(s.Player, s.Catalog, completedAt)...) - } - if err := s.Saver.Save(*s.Player); err != nil { return SessionResult{}, err } - s.reportAttempt(AttemptCompleted, AttemptStateCompleted, currentOutcomes, hintsUsed, replaying, xp, firstCompletion, practiced, discovered, unlocked) - if s.Companion { - printCompanionCompletion(s.Out, xp, firstCompletion, unlocked, s.Style) - } else { - printCompletion(s.Out, s.Mission, xp, firstCompletion, practiced, discovered, unlocked, s.Style) + if next.done { + return next.result, nil } - return SessionResult{Completed: true, XPAwarded: xp, HintsUsed: hintsUsed, WorldRoute: worldRoute}, nil } } -func (s Session) reportAttempt(eventType AttemptEventType, state string, outcomes []outcomeResult, hintsUsed int, replaying bool, xp int, firstCompletion bool, practiced, discovered []string, unlocked []profile.Achievement) { - if s.Reporter == nil { - return - } - placement, _ := s.Catalog.Placement(s.Mission.ID) - revealedCount := min(hintsUsed, len(s.Mission.Hints)) - revealedHints := slices.Clone(s.Mission.Hints[:revealedCount]) - publicOutcomes := make([]AttemptOutcome, len(outcomes)) - for index, outcome := range outcomes { - publicOutcomes[index] = AttemptOutcome{Description: outcome.Description, Satisfied: outcome.Satisfied} - } - achievements := make([]string, len(unlocked)) - for index, achievement := range unlocked { - achievements[index] = achievement.Title - } - explanation := "" - if state == AttemptStateCompleted { - explanation = s.Mission.Explanation - } - s.Reporter.ReportAttempt(AttemptEvent{ - Type: eventType, - Snapshot: AttemptSnapshot{ - MissionID: s.Mission.ID, - Number: s.Mission.Number, - Title: s.Mission.Title, - Track: s.Mission.EffectiveTrack(), - WorldNumber: placement.WorldNumber, - WorldTotal: placement.WorldTotal, - WorldName: placement.WorldName, - StageNumber: placement.StageNumber, - StageTotal: placement.StageTotal, - Difficulty: s.Mission.Difficulty, - Story: s.Mission.Story, - Objective: s.Mission.Objective, - SuggestedCommands: slices.Clone(s.Mission.SuggestedCommands), - RevealedHints: revealedHints, - HintCount: len(s.Mission.Hints), - HintsUsed: hintsUsed, - Outcomes: publicOutcomes, - SatisfiedOutcomes: satisfiedOutcomeCount(outcomes), - RewardAvailable: AdjustedReward(s.Mission, hintsUsed), - BaseReward: s.Mission.Rewards.XP, - Replaying: replaying, - State: state, - Explanation: explanation, - XPAwarded: xp, - FirstCompletion: firstCompletion, - PracticedCommands: slices.Clone(practiced), - DiscoveredCommands: slices.Clone(discovered), - UnlockedAchievements: achievements, - }, - }) +// attempt is the mutable state of one running mission session. +type attempt struct { + Session + ctx context.Context + factory Factory + environment *managedEnvironment + reader CommandLineReader + replaying bool + hintsUsed int + outcomes []outcomeResult + practiced []string + discovered []string + lastOutput string + worldRoute int } -func printMission(out io.Writer, item mission.Mission, hintsUsed int, completed bool, catalog mission.Catalog, style ui.Style) { - heading := fmt.Sprintf("MISSION %02d: %s", item.Number, item.Title) - fmt.Fprintf(out, "\n%s\n", style.Header(heading)) - fmt.Fprintln(out, style.Muted(strings.Repeat("=", len(item.Title)+12))) - if placement, found := catalog.Placement(item.ID); found { - location := fmt.Sprintf("%s · World %d/%d: %s · Stage %d/%d", - displayTrackName(placement.Track), placement.WorldNumber, placement.WorldTotal, - placement.WorldName, placement.StageNumber, placement.StageTotal) - fmt.Fprintln(out, style.World(location)) - } else { - fmt.Fprintf(out, "Campaign: %s\n", style.World(item.Campaign)) - } - reward := style.Reward(fmt.Sprintf("%d XP", AdjustedReward(item, hintsUsed))) - if completed { - reward = style.Accent(fmt.Sprintf("already claimed · %d XP base", item.Rewards.XP)) - } - fmt.Fprintf(out, "Difficulty: %s · Reward: %s\n", style.Difficulty(item.Difficulty), reward) - if hintsUsed > 0 { - fmt.Fprintln(out, style.Warning(fmt.Sprintf("Hints already used: %d/%d", hintsUsed, len(item.Hints)))) - } - fmt.Fprintf(out, "\n%s\n%s\n\n", style.Section("INCIDENT"), item.Story) - fmt.Fprintf(out, "%s\n%s\n\n", style.Section("OBJECTIVE"), item.Objective) - fmt.Fprintf(out, "%s\n\n", style.CommandGuide(item.SuggestedCommands)) - printCompactMissionControls(out, style) +// step is the effect of one input line: done ends the session with result. +type step struct { + done bool + result SessionResult } -func displayTrackName(track string) string { - switch track { - case mission.TrackDocker: - return "Docker" - case mission.TrackLinux: - return "Linux" - default: - return track - } +func finish(result SessionResult) step { + return step{done: true, result: result} } -func printCompactMissionControls(out io.Writer, style ui.Style) { - fmt.Fprintf(out, "Controls: %s for the full guide\n", accented(style, " · ", "hint", "objective", "status", "restart", "quit", "?")) - fmt.Fprintf(out, "Navigate: %s\n", accented(style, " · ", "map", "world N", "play STAGE/ID", "next", "previous")) - fmt.Fprintf(out, "Lab: %s lists commands · %s completes · arrows edit and recall history\n", - style.Accent("help"), style.Accent("Tab")) -} - -func printMissionControls(out io.Writer, style ui.Style) { - fmt.Fprintln(out, style.Section("MISSION GUIDE")) - fmt.Fprintf(out, " Controls: %s\n", accented(style, ", ", "hint", "objective", "status", "restart", "quit")) - fmt.Fprintf(out, " Navigate: %s\n", accented(style, ", ", "map", "world N", "play STAGE/ID", "next", "previous")) - fmt.Fprintf(out, " Lab commands: %s or %s. Valid solutions are judged by their result.\n", style.Accent("help"), style.Accent("help COMMAND")) - fmt.Fprintf(out, " Line editing: arrows move and recall history; %s or %s jump across the line.\n", - style.Accent("Home/End"), style.Accent("Ctrl-A/E")) - fmt.Fprintf(out, " %s move by word; %s completes; %s, %s, and %s remove text.\n", - style.Accent("Option/Ctrl-Left/Right"), style.Accent("Tab"), style.Accent("Backspace"), style.Accent("Delete"), style.Accent("Ctrl-W")) - fmt.Fprintln(out, " Prefix navigation with opsquest if you prefer; both forms are equivalent inside a mission.") -} - -func accented(style ui.Style, separator string, values ...string) string { - styled := make([]string, len(values)) - for index, value := range values { - styled[index] = style.Accent(value) +// begin validates the session, applies defaults, and creates the first +// mission environment. +func (s Session) begin() (*attempt, error) { + if s.Player == nil { + return nil, fmt.Errorf("mission session requires a player profile") } - return strings.Join(styled, separator) -} - -func missionNavigationFields(line string) ([]string, bool, error) { - fields, err := splitMissionNavigation(line) - if len(fields) == 0 { - return nil, false, nil + if s.Saver == nil { + return nil, fmt.Errorf("mission session requires profile persistence") } - prefixed := fields[0] == "opsquest" - if prefixed { - fields = fields[1:] - if len(fields) == 0 { - if err != nil { - return nil, true, err - } - return []string{"missions"}, true, nil - } + if s.In == nil { + s.In = strings.NewReader("") } - switch fields[0] { - case "list", "missions", "map", "worlds", "world", "play", "next", "previous", "prev": - return fields, true, err - default: - if prefixed && err != nil { - return nil, true, err - } - // Non-navigation input belongs to the active environment, including its - // own quote and escape errors. - return nil, false, nil + if s.Out == nil { + s.Out = io.Discard } -} - -// splitMissionNavigation parses the small, host-independent argument syntax -// accepted by in-mission navigation. It deliberately performs no expansion or -// command execution: whitespace separates fields, quotes group text, and a -// backslash escapes the next character outside single quotes. -func splitMissionNavigation(line string) ([]string, error) { - fields := make([]string, 0) - var field strings.Builder - var quote rune - escaped := false - started := false - - flush := func() { - if !started { - return - } - fields = append(fields, field.String()) - field.Reset() - started = false + if s.ErrOut == nil { + s.ErrOut = io.Discard } - - for _, char := range line { - if escaped { - field.WriteRune(char) - escaped = false - started = true - continue - } - if quote != 0 { - if char == quote { - quote = 0 - continue - } - if quote == '"' && char == '\\' { - escaped = true - continue - } - field.WriteRune(char) - continue - } - - switch { - case unicode.IsSpace(char): - flush() - case char == '\'' || char == '"': - quote = char - started = true - case char == '\\': - escaped = true - started = true - default: - field.WriteRune(char) - started = true - } + a := &attempt{Session: s, ctx: defaultContext(s.Context), factory: defaultFactory(s.Factory)} + environment, err := createManagedEnvironment(a.ctx, a.factory, s.Mission) + if err != nil { + return nil, fmt.Errorf("prepare mission: %w", err) } - - if escaped { - flush() - return fields, fmt.Errorf("unfinished escape") + a.environment = environment + if a.Now == nil { + a.Now = time.Now } - if quote != 0 { - flush() - name := "single" - if quote == '"' { - name = "double" - } - return fields, fmt.Errorf("unterminated %s quote", name) + a.replaying = s.Player.IsComplete(s.Mission.ID) + a.hintsUsed = s.Player.MissionHints(s.Mission.ID) + a.reader = s.Reader + if a.reader == nil { + a.reader = NewCommandLineReader(a.In, a.Out) } - flush() - return fields, nil -} - -func printMissionSwitch(out io.Writer, target mission.Mission, style ui.Style) { - message := fmt.Sprintf("Switching to Mission %02d: %s. The current mission environment will reset.", target.Number, target.Title) - fmt.Fprintln(out, style.Accent(message)) + return a, nil } -func printOutcomeStatus(out io.Writer, outcomes []outcomeResult, style ui.Style) { - message := fmt.Sprintf("Outcome checks satisfied: %d/%d.", satisfiedOutcomeCount(outcomes), len(outcomes)) - fmt.Fprintln(out, style.Accent(message)) - for _, outcome := range outcomes { - marker := style.Progress("", "○") - if outcome.Satisfied { - marker = style.Progress("✓", "") +// announce reports the starting state to the companion and presents the +// mission in the terminal. +func (a *attempt) announce() error { + if a.Reporter != nil { + outcomes, err := evaluateOutcomes(a.ctx, a.Mission.Validation, a.environment.Environment, "") + if err != nil { + return fmt.Errorf("prepare companion mission status: %w", err) } - fmt.Fprintf(out, " %s %s\n", marker, outcome.Description) + a.outcomes = outcomes + a.report(AttemptStarted, AttemptStateActive, nil) } + if a.Companion { + fmt.Fprintf(a.Out, "\nMission %02d ready in the web companion. Enter lab commands below.\n", a.Mission.Number) + } else { + printMission(a.Out, a.Mission, a.hintsUsed, a.replaying, a.Catalog, a.Style) + } + return nil } -// AdjustedReward returns the XP still available after hints while preserving -// the mission's minimum quarter-reward floor. -func AdjustedReward(item mission.Mission, hintsUsed int) int { - return max(item.Rewards.XP-hintsUsed*item.Rewards.HintPenalty, item.Rewards.XP/4) -} - -func printCompletion(out io.Writer, item mission.Mission, xp int, first bool, practiced, discovered []string, unlocked []profile.Achievement, style ui.Style) { - fmt.Fprintf(out, "\n%s\n", style.Success("✓ Mission complete!")) - if first { - fmt.Fprintln(out, style.Reward(fmt.Sprintf("+%d XP", xp))) - } else { - fmt.Fprintln(out, style.Accent("Replay complete — XP was already claimed.")) +// handle routes one input line to navigation, a built-in lab command, or the +// mission environment. +func (a *attempt) handle(line string) (step, error) { + if fields, navigation, err := missionNavigationFields(line); navigation { + if err != nil { + a.fail("invalid mission navigation: " + err.Error()) + return step{}, nil + } + if next, handled := a.navigate(fields); handled { + return next, nil + } } - if len(discovered) == 1 { - fmt.Fprintln(out, style.Accent(fmt.Sprintf("New command discovered: %s", discovered[0]))) - } else if len(discovered) > 1 { - fmt.Fprintln(out, style.Accent(fmt.Sprintf("New commands discovered: %s", strings.Join(discovered, ", ")))) - } else if len(practiced) == 1 { - fmt.Fprintln(out, style.Accent(fmt.Sprintf("Command practiced: %s", practiced[0]))) - } else if len(practiced) > 1 { - fmt.Fprintln(out, style.Accent(fmt.Sprintf("Commands practiced: %s", strings.Join(practiced, ", ")))) + switch line { + case "quit", "exit", ":q": + fmt.Fprintln(a.Out, a.Style.Accent("Mission paused. Your profile progress is safe.")) + return finish(a.pause()), nil + case "hint": + return step{}, a.revealHint() + case "objective": + a.showObjective() + return step{}, nil + case "status": + return step{}, a.refreshStatus() + case "restart": + return step{}, a.restart() + case "?", "guide": + a.showControls() + return step{}, nil } - printAchievements(out, unlocked, style) - fmt.Fprintf(out, "\n%s\n", item.Explanation) + return a.execute(line) } -func printCompanionCompletion(out io.Writer, xp int, first bool, unlocked []profile.Achievement, style ui.Style) { - fmt.Fprintf(out, "\n%s\n", style.Success("✓ Mission complete!")) - if first { - fmt.Fprintln(out, style.Reward(fmt.Sprintf("+%d XP", xp))) - } else { - fmt.Fprintln(out, style.Accent("Replay complete — XP was already claimed.")) - } - printAchievements(out, unlocked, style) - fmt.Fprintln(out, style.Accent("The explanation and completed objective are shown in the web companion.")) +// pause reports the paused attempt and ends the session without completing. +func (a *attempt) pause() SessionResult { + a.report(AttemptPaused, AttemptStatePaused, nil) + return SessionResult{Quit: true, HintsUsed: a.hintsUsed} } -func printAchievements(out io.Writer, unlocked []profile.Achievement, style ui.Style) { - for _, achievement := range unlocked { - fmt.Fprintf(out, "%s %s — %s\n", style.Achievement("★ Achievement unlocked:"), achievement.Title, achievement.Description) - } +// report sends the current attempt state to the companion reporter. +func (a *attempt) report(eventType AttemptEventType, state string, unlocked []profile.Achievement) { + a.reportAttempt(eventType, state, a.outcomes, a.hintsUsed, a.replaying, 0, false, a.practiced, a.discovered, unlocked) +} + +func (a *attempt) fail(message string) { + fmt.Fprintln(a.ErrOut, a.ErrorStyle.Failure(message)) } diff --git a/internal/game/session_commands.go b/internal/game/session_commands.go new file mode 100644 index 0000000..073d8b3 --- /dev/null +++ b/internal/game/session_commands.go @@ -0,0 +1,91 @@ +package game + +import "fmt" + +// revealHint shows the next hint and, on a first attempt, records it so the +// reduced reward survives a pause. +func (a *attempt) revealHint() error { + if a.hintsUsed >= len(a.Mission.Hints) { + fmt.Fprintln(a.Out, a.Style.Warning("No more hints. ByteWorks has exhausted its documentation budget.")) + return nil + } + cost := 0 + if a.replaying { + a.hintsUsed++ + } else { + before := AdjustedReward(a.Mission, a.hintsUsed) + a.hintsUsed = a.Player.RecordHint(a.Mission.ID) + if err := a.Saver.Save(*a.Player); err != nil { + return err + } + cost = before - AdjustedReward(a.Mission, a.hintsUsed) + } + costLabel := fmt.Sprintf("-%d XP", cost) + if cost == 0 { + costLabel = "no XP cost" + } + a.report(AttemptHint, AttemptStateActive, nil) + if a.Companion { + message := fmt.Sprintf("Hint %d/%d revealed in the web companion (%s).", a.hintsUsed, len(a.Mission.Hints), costLabel) + fmt.Fprintln(a.Out, a.Style.Warning(message)) + } else { + prefix := fmt.Sprintf("Hint %d/%d (%s):", a.hintsUsed, len(a.Mission.Hints), costLabel) + fmt.Fprintf(a.Out, "%s %s\n", a.Style.Warning(prefix), a.Mission.Hints[a.hintsUsed-1]) + } + return nil +} + +func (a *attempt) showObjective() { + if a.Companion { + fmt.Fprintln(a.Out, a.Style.Accent("The objective and suggested commands are shown in the web companion.")) + return + } + fmt.Fprintf(a.Out, "%s\n%s\n\n%s\n", a.Style.Section("OBJECTIVE"), a.Mission.Objective, a.Style.CommandGuide(a.Mission.SuggestedCommands)) +} + +func (a *attempt) refreshStatus() error { + outcomes, err := evaluateOutcomes(a.ctx, a.Mission.Validation, a.environment.Environment, a.lastOutput) + if err != nil { + return fmt.Errorf("check mission status: %w", err) + } + a.outcomes = outcomes + a.report(AttemptProgress, AttemptStateActive, nil) + if a.Companion { + fmt.Fprintln(a.Out, a.Style.Accent("Mission status refreshed in the web companion.")) + } else { + printOutcomeStatus(a.Out, outcomes, a.Style) + } + return nil +} + +// restart replaces the environment with a fresh one. Hints and command +// practice belong to the player, so they are kept. +func (a *attempt) restart() error { + if err := a.environment.close(); err != nil { + return fmt.Errorf("restart mission: close current environment: %w", err) + } + environment, err := createManagedEnvironment(a.ctx, a.factory, a.Mission) + a.environment = environment + if err != nil { + return fmt.Errorf("restart mission: %w", err) + } + a.lastOutput = "" + if a.Reporter != nil { + outcomes, err := evaluateOutcomes(a.ctx, a.Mission.Validation, a.environment.Environment, "") + if err != nil { + return fmt.Errorf("refresh companion mission status: %w", err) + } + a.outcomes = outcomes + a.report(AttemptRestarted, AttemptStateActive, nil) + } + fmt.Fprintln(a.Out, a.Style.Accent("Mission environment restarted. Hints and command mastery are retained.")) + return nil +} + +func (a *attempt) showControls() { + if a.Companion { + fmt.Fprintln(a.Out, a.Style.Accent("Mission controls and guidance are shown in the web companion.")) + return + } + printMissionControls(a.Out, a.Style) +} diff --git a/internal/game/session_execute.go b/internal/game/session_execute.go new file mode 100644 index 0000000..cb9113b --- /dev/null +++ b/internal/game/session_execute.go @@ -0,0 +1,131 @@ +package game + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +// execute runs one command in the mission environment, records practice, and +// completes the mission once every outcome holds. +func (a *attempt) execute(line string) (step, error) { + result, ran, err := a.runCommand(line) + if err != nil || !ran { + return step{}, err + } + if result.Output != "" { + fmt.Fprint(a.Out, result.Output) + if !strings.HasSuffix(result.Output, "\n") { + fmt.Fprintln(a.Out) + } + } + a.lastOutput = result.Output + unlocked, err := a.recordPractice(result) + if err != nil { + return step{}, err + } + outcomes, err := evaluateOutcomes(a.ctx, a.Mission.Validation, a.environment.Environment, result.Output) + if err != nil { + return step{}, fmt.Errorf("validate mission: %w", err) + } + a.outcomes = outcomes + a.report(AttemptProgress, AttemptStateActive, unlocked) + if !allOutcomesSatisfied(outcomes) { + printAchievements(a.Out, unlocked, a.Style) + if !a.Companion { + message := fmt.Sprintf("Progress — %d/%d outcome checks satisfied. Type status to see what remains.", satisfiedOutcomeCount(outcomes), len(outcomes)) + fmt.Fprintln(a.Out, a.Style.Accent(message)) + } + return step{}, nil + } + completed, err := a.complete(unlocked) + if err != nil { + return step{}, err + } + return finish(completed), nil +} + +// runCommand executes line and any interactive follow-up. ran is false when +// a recoverable command error was already shown to the player. +func (a *attempt) runCommand(line string) (Execution, bool, error) { + result, executeErr := a.environment.Execute(a.ctx, line) + if executeErr != nil { + if err := a.ctx.Err(); err != nil { + return Execution{}, false, fmt.Errorf("execute command: %w", err) + } + a.fail(executeErr.Error()) + return Execution{}, false, nil + } + if err := a.ctx.Err(); err != nil { + return Execution{}, false, fmt.Errorf("execute command: %w", err) + } + if result.Interactive == nil { + return result, true, nil + } + if result.Interactive.Run == nil { + return Execution{}, false, fmt.Errorf("run %s: interactive action has no runner", result.Interactive.Command) + } + if err := result.Interactive.Run(a.reader); err != nil { + if errors.Is(err, ErrInteractiveEditor) || errors.Is(err, ErrUnsupportedEditorFile) { + a.fail(fmt.Sprintf("%s: %v", result.Interactive.Command, err)) + return Execution{}, false, nil + } + return Execution{}, false, fmt.Errorf("run %s: %w", result.Interactive.Command, err) + } + return result, true, nil +} + +// recordPractice credits the commands a successful execution used, unlocks +// command achievements, and saves the profile. +func (a *attempt) recordPractice(result Execution) ([]profile.Achievement, error) { + for _, command := range result.PracticedCommands { + if !slices.Contains(a.practiced, command) { + a.practiced = append(a.practiced, command) + } + if a.Player.Commands[command] == 0 && !slices.Contains(a.discovered, command) { + a.discovered = append(a.discovered, command) + } + } + a.Player.RecordCommands(result.PracticedCommands) + unlocked := make([]profile.Achievement, 0) + achievementTime := a.Now() + if result.PipelineWidth >= 3 { + if achievement, added := a.Player.UnlockAchievement(profile.AchievementPipeDream, achievementTime); added { + unlocked = append(unlocked, achievement) + } + } + unlocked = append(unlocked, ReconcileCommandAchievements(a.Player, achievementTime)...) + if err := a.Saver.Save(*a.Player); err != nil { + return nil, err + } + return unlocked, nil +} + +// complete closes the environment before awarding XP, so an attempt whose +// cleanup failed is never recorded as complete. +func (a *attempt) complete(unlocked []profile.Achievement) (SessionResult, error) { + if err := a.environment.close(); err != nil { + return SessionResult{}, fmt.Errorf("complete mission: close environment before awarding XP: %w", err) + } + xp := AdjustedReward(a.Mission, a.hintsUsed) + completedAt := a.Now() + firstCompletion := a.Player.Complete(a.Mission.ID, xp, a.hintsUsed, completedAt) + if !firstCompletion { + xp = 0 + } else { + unlocked = append(unlocked, ReconcileAchievements(a.Player, a.Catalog, completedAt)...) + } + if err := a.Saver.Save(*a.Player); err != nil { + return SessionResult{}, err + } + a.reportAttempt(AttemptCompleted, AttemptStateCompleted, a.outcomes, a.hintsUsed, a.replaying, xp, firstCompletion, a.practiced, a.discovered, unlocked) + if a.Companion { + printCompanionCompletion(a.Out, xp, firstCompletion, unlocked, a.Style) + } else { + printCompletion(a.Out, a.Mission, xp, firstCompletion, a.practiced, a.discovered, unlocked, a.Style) + } + return SessionResult{Completed: true, XPAwarded: xp, HintsUsed: a.hintsUsed, WorldRoute: a.worldRoute}, nil +} diff --git a/internal/game/session_lifecycle_test.go b/internal/game/session_lifecycle_test.go new file mode 100644 index 0000000..8251912 --- /dev/null +++ b/internal/game/session_lifecycle_test.go @@ -0,0 +1,310 @@ +package game + +import ( + "bytes" + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +func TestSessionRejectsMissingCoreDependencies(t *testing.T) { + player := profile.New("tester") + if _, err := (Session{}).Run(); err == nil || !strings.Contains(err.Error(), "player profile") { + t.Fatalf("Session without player error = %v", err) + } + if _, err := (Session{Player: &player}).Run(); err == nil || !strings.Contains(err.Error(), "profile persistence") { + t.Fatalf("Session without saver error = %v", err) + } +} + +func TestSessionUsesFactoryContextAndClosesBeforeAwardingXP(t *testing.T) { + catalog, item := seamCatalogMission(t, "1") + player := profile.New("tester") + completionSource := &seamCompletionSource{commands: []string{"solve"}} + type contextKey string + ctx := context.WithValue(context.Background(), contextKey("attempt"), "expected") + closedBeforeXP := false + environment := &seamEnvironment{ + prompt: "/generic", + completions: completionSource, + execute: func(commandContext context.Context, line string) (Execution, error) { + if commandContext.Value(contextKey("attempt")) != "expected" { + return Execution{}, errors.New("session context was not propagated") + } + if line != "solve" { + return Execution{}, fmt.Errorf("unexpected command %q", line) + } + return Execution{Output: "/home/operator\n", PracticedCommands: []string{"solve"}}, nil + }, + observe: func(_ context.Context, condition mission.Condition) (bool, error) { + return false, fmt.Errorf("output condition %s was delegated", condition.Type) + }, + close: func() error { + closedBeforeXP = player.XP == 0 && !player.IsComplete(item.ID) + return nil + }, + } + createCalls := 0 + factory := FactoryFunc(func(factoryContext context.Context, created mission.Mission) (Environment, error) { + createCalls++ + if factoryContext.Value(contextKey("attempt")) != "expected" || created.ID != item.ID { + return nil, errors.New("factory received the wrong context or mission") + } + return environment, nil + }) + reader := &seamReader{lines: []string{"solve"}} + out := &bytes.Buffer{} + session := Session{ + Mission: item, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Out: out, + ErrOut: &bytes.Buffer{}, + Reader: reader, + Catalog: catalog, + Context: ctx, + Factory: factory, + Now: func() time.Time { return time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) }, + } + + result, err := session.Run() + if err != nil { + t.Fatal(err) + } + if !result.Completed || result.XPAwarded != 40 || player.XP != 40 { + t.Fatalf("result = %#v, player XP = %d", result, player.XP) + } + if createCalls != 1 || environment.closeCount != 1 || !closedBeforeXP { + t.Fatalf("create calls = %d, close calls = %d, closed before XP = %v", createCalls, environment.closeCount, closedBeforeXP) + } + if len(reader.prompts) != 1 || reader.prompts[0] != "opsquest:/generic$ " { + t.Fatalf("prompts = %#v", reader.prompts) + } + if len(reader.completions) != 1 || reader.completions[0] != completionSource { + t.Fatalf("reader completions = %#v, want factory source", reader.completions) + } + if !strings.Contains(out.String(), "\n/home/operator\n") { + t.Fatalf("execution output was not kept raw: %q", out.String()) + } +} + +func TestSessionClosesEnvironmentOnEveryTerminalPath(t *testing.T) { + catalog, first := seamCatalogMission(t, "1") + _, worldTwoFirst := seamCatalogMission(t, "linux-permissions") + _, stateMission := seamCatalogMission(t, "linux-workspace") + readFailure := errors.New("read failed") + observeFailure := errors.New("observe failed") + + tests := []struct { + name string + item mission.Mission + reader *seamReader + execute func(context.Context, string) (Execution, error) + observe func(context.Context, mission.Condition) (bool, error) + wantQuit bool + wantSwitch string + wantWorld int + wantErrIs error + }{ + {name: "quit", item: first, reader: &seamReader{lines: []string{"quit"}}, wantQuit: true}, + {name: "EOF", item: first, reader: &seamReader{}, wantQuit: true}, + {name: "switch", item: first, reader: &seamReader{lines: []string{"next"}}, wantSwitch: "linux-config-crawl"}, + {name: "stage switch", item: worldTwoFirst, reader: &seamReader{lines: []string{"play 4"}}, wantSwitch: "linux-runaway"}, + {name: "world switch", item: first, reader: &seamReader{lines: []string{"world 2"}}, wantSwitch: "linux-permissions", wantWorld: 2}, + {name: "read error", item: first, reader: &seamReader{endErr: readFailure}, wantErrIs: readFailure}, + { + name: "validation error", + item: stateMission, + reader: &seamReader{lines: []string{"change"}}, + execute: func(context.Context, string) (Execution, error) { + return Execution{}, nil + }, + observe: func(context.Context, mission.Condition) (bool, error) { + return false, observeFailure + }, + wantErrIs: observeFailure, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + environment := &seamEnvironment{prompt: "/test", execute: test.execute, observe: test.observe} + player := profile.New("tester") + session := Session{ + Mission: test.item, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Reader: test.reader, + Catalog: catalog, + Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { + return environment, nil + }), + } + + result, err := session.Run() + if test.wantErrIs == nil && err != nil { + t.Fatal(err) + } + if test.wantErrIs != nil && !errors.Is(err, test.wantErrIs) { + t.Fatalf("Run() error = %v, want errors.Is(..., %v)", err, test.wantErrIs) + } + if result.Quit != test.wantQuit || result.SwitchMission != test.wantSwitch || result.WorldRoute != test.wantWorld { + t.Fatalf("result = %#v", result) + } + if environment.closeCount != 1 { + t.Fatalf("Close() calls = %d, want 1", environment.closeCount) + } + }) + } +} + +func TestSessionRestartClosesAndRecreatesEnvironment(t *testing.T) { + catalog, item := seamCatalogMission(t, "1") + first := &seamEnvironment{prompt: "/first"} + second := &seamEnvironment{prompt: "/second"} + created := 0 + factory := FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { + created++ + switch created { + case 1: + return first, nil + case 2: + if first.closeCount != 1 { + return nil, fmt.Errorf("first environment was not closed before recreation") + } + return second, nil + default: + return nil, fmt.Errorf("unexpected create call %d", created) + } + }) + reader := &seamReader{lines: []string{"restart", "quit"}} + player := profile.New("tester") + session := Session{ + Mission: item, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Reader: reader, + Catalog: catalog, + Factory: factory, + } + + result, err := session.Run() + if err != nil { + t.Fatal(err) + } + if !result.Quit || created != 2 || first.closeCount != 1 || second.closeCount != 1 { + t.Fatalf("result = %#v, created = %d, close counts = %d/%d", result, created, first.closeCount, second.closeCount) + } + if len(reader.prompts) != 2 || reader.prompts[0] != "opsquest:/first$ " || reader.prompts[1] != "opsquest:/second$ " { + t.Fatalf("restart prompts = %#v", reader.prompts) + } +} + +func TestSessionCancellationDuringExecutionClosesEnvironment(t *testing.T) { + catalog, item := seamCatalogMission(t, "1") + ctx, cancel := context.WithCancel(context.Background()) + environment := &seamEnvironment{ + prompt: "/test", + execute: func(context.Context, string) (Execution, error) { + cancel() + return Execution{}, context.Canceled + }, + } + player := profile.New("tester") + session := Session{ + Mission: item, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"solve"}}, + Catalog: catalog, + Context: ctx, + Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { return environment, nil }), + } + + _, err := session.Run() + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context cancellation", err) + } + if environment.closeCount != 1 { + t.Fatalf("Close() calls = %d, want 1", environment.closeCount) + } +} + +func TestCompletionCleanupFailurePreventsXPAndIsRetriedByDefer(t *testing.T) { + catalog, item := seamCatalogMission(t, "1") + closeFailure := errors.New("cleanup failed") + closeAttempts := 0 + environment := &seamEnvironment{ + prompt: "/test", + execute: func(context.Context, string) (Execution, error) { + return Execution{Output: "/home/operator\n"}, nil + }, + close: func() error { + closeAttempts++ + if closeAttempts == 1 { + return closeFailure + } + return nil + }, + } + player := profile.New("tester") + session := Session{ + Mission: item, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"solve"}}, + Catalog: catalog, + Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { return environment, nil }), + } + + _, err := session.Run() + if !errors.Is(err, closeFailure) { + t.Fatalf("Run() error = %v, want cleanup failure", err) + } + if player.XP != 0 || player.IsComplete(item.ID) { + t.Fatalf("cleanup failure awarded completion: XP = %d, completed = %v", player.XP, player.IsComplete(item.ID)) + } + if environment.closeCount != 2 { + t.Fatalf("Close() calls = %d, want explicit attempt plus deferred retry", environment.closeCount) + } +} + +func TestDeferredCleanupFailureIsReturnedOnce(t *testing.T) { + catalog, item := seamCatalogMission(t, "1") + closeFailure := errors.New("cleanup failed") + environment := &seamEnvironment{prompt: "/test", close: func() error { return closeFailure }} + player := profile.New("tester") + session := Session{ + Mission: item, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"quit"}}, + Catalog: catalog, + Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { return environment, nil }), + } + + result, err := session.Run() + if !result.Quit || !errors.Is(err, closeFailure) { + t.Fatalf("result = %#v, error = %v", result, err) + } + if environment.closeCount != 1 { + t.Fatalf("Close() calls = %d, want 1", environment.closeCount) + } +} diff --git a/internal/game/session_output.go b/internal/game/session_output.go new file mode 100644 index 0000000..3ff29c3 --- /dev/null +++ b/internal/game/session_output.go @@ -0,0 +1,129 @@ +package game + +import ( + "fmt" + "io" + "strings" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" + "github.com/aleksandergregersen/opsquest/internal/ui" +) + +func printMission(out io.Writer, item mission.Mission, hintsUsed int, completed bool, catalog mission.Catalog, style ui.Style) { + heading := fmt.Sprintf("MISSION %02d: %s", item.Number, item.Title) + fmt.Fprintf(out, "\n%s\n", style.Header(heading)) + fmt.Fprintln(out, style.Muted(strings.Repeat("=", len(item.Title)+12))) + if placement, found := catalog.Placement(item.ID); found { + location := fmt.Sprintf("%s · World %d/%d: %s · Stage %d/%d", + displayTrackName(placement.Track), placement.WorldNumber, placement.WorldTotal, + placement.WorldName, placement.StageNumber, placement.StageTotal) + fmt.Fprintln(out, style.World(location)) + } else { + fmt.Fprintf(out, "Campaign: %s\n", style.World(item.Campaign)) + } + reward := style.Reward(fmt.Sprintf("%d XP", AdjustedReward(item, hintsUsed))) + if completed { + reward = style.Accent(fmt.Sprintf("already claimed · %d XP base", item.Rewards.XP)) + } + fmt.Fprintf(out, "Difficulty: %s · Reward: %s\n", style.Difficulty(item.Difficulty), reward) + if hintsUsed > 0 { + fmt.Fprintln(out, style.Warning(fmt.Sprintf("Hints already used: %d/%d", hintsUsed, len(item.Hints)))) + } + fmt.Fprintf(out, "\n%s\n%s\n\n", style.Section("INCIDENT"), item.Story) + fmt.Fprintf(out, "%s\n%s\n\n", style.Section("OBJECTIVE"), item.Objective) + fmt.Fprintf(out, "%s\n\n", style.CommandGuide(item.SuggestedCommands)) + printCompactMissionControls(out, style) +} + +func displayTrackName(track string) string { + switch track { + case mission.TrackDocker: + return "Docker" + case mission.TrackLinux: + return "Linux" + default: + return track + } +} + +func printCompactMissionControls(out io.Writer, style ui.Style) { + fmt.Fprintf(out, "Controls: %s for the full guide\n", accented(style, " · ", "hint", "objective", "status", "restart", "quit", "?")) + fmt.Fprintf(out, "Navigate: %s\n", accented(style, " · ", "map", "world N", "play STAGE/ID", "next", "previous")) + fmt.Fprintf(out, "Lab: %s lists commands · %s completes · arrows edit and recall history\n", + style.Accent("help"), style.Accent("Tab")) +} + +func printMissionControls(out io.Writer, style ui.Style) { + fmt.Fprintln(out, style.Section("MISSION GUIDE")) + fmt.Fprintf(out, " Controls: %s\n", accented(style, ", ", "hint", "objective", "status", "restart", "quit")) + fmt.Fprintf(out, " Navigate: %s\n", accented(style, ", ", "map", "world N", "play STAGE/ID", "next", "previous")) + fmt.Fprintf(out, " Lab commands: %s or %s. Valid solutions are judged by their result.\n", style.Accent("help"), style.Accent("help COMMAND")) + fmt.Fprintf(out, " Line editing: arrows move and recall history; %s or %s jump across the line.\n", + style.Accent("Home/End"), style.Accent("Ctrl-A/E")) + fmt.Fprintf(out, " %s move by word; %s completes; %s, %s, and %s remove text.\n", + style.Accent("Option/Ctrl-Left/Right"), style.Accent("Tab"), style.Accent("Backspace"), style.Accent("Delete"), style.Accent("Ctrl-W")) + fmt.Fprintln(out, " Prefix navigation with opsquest if you prefer; both forms are equivalent inside a mission.") +} + +func accented(style ui.Style, separator string, values ...string) string { + styled := make([]string, len(values)) + for index, value := range values { + styled[index] = style.Accent(value) + } + return strings.Join(styled, separator) +} + +func printMissionSwitch(out io.Writer, target mission.Mission, style ui.Style) { + message := fmt.Sprintf("Switching to Mission %02d: %s. The current mission environment will reset.", target.Number, target.Title) + fmt.Fprintln(out, style.Accent(message)) +} + +func printOutcomeStatus(out io.Writer, outcomes []outcomeResult, style ui.Style) { + message := fmt.Sprintf("Outcome checks satisfied: %d/%d.", satisfiedOutcomeCount(outcomes), len(outcomes)) + fmt.Fprintln(out, style.Accent(message)) + for _, outcome := range outcomes { + marker := style.Progress("", "○") + if outcome.Satisfied { + marker = style.Progress("✓", "") + } + fmt.Fprintf(out, " %s %s\n", marker, outcome.Description) + } +} + +func printCompletion(out io.Writer, item mission.Mission, xp int, first bool, practiced, discovered []string, unlocked []profile.Achievement, style ui.Style) { + fmt.Fprintf(out, "\n%s\n", style.Success("✓ Mission complete!")) + if first { + fmt.Fprintln(out, style.Reward(fmt.Sprintf("+%d XP", xp))) + } else { + fmt.Fprintln(out, style.Accent("Replay complete — XP was already claimed.")) + } + if len(discovered) == 1 { + fmt.Fprintln(out, style.Accent(fmt.Sprintf("New command discovered: %s", discovered[0]))) + } else if len(discovered) > 1 { + fmt.Fprintln(out, style.Accent(fmt.Sprintf("New commands discovered: %s", strings.Join(discovered, ", ")))) + } else if len(practiced) == 1 { + fmt.Fprintln(out, style.Accent(fmt.Sprintf("Command practiced: %s", practiced[0]))) + } else if len(practiced) > 1 { + fmt.Fprintln(out, style.Accent(fmt.Sprintf("Commands practiced: %s", strings.Join(practiced, ", ")))) + } + printAchievements(out, unlocked, style) + fmt.Fprintf(out, "\n%s\n", item.Explanation) +} + +func printCompanionCompletion(out io.Writer, xp int, first bool, unlocked []profile.Achievement, style ui.Style) { + fmt.Fprintf(out, "\n%s\n", style.Success("✓ Mission complete!")) + if first { + fmt.Fprintln(out, style.Reward(fmt.Sprintf("+%d XP", xp))) + } else { + fmt.Fprintln(out, style.Accent("Replay complete — XP was already claimed.")) + } + printAchievements(out, unlocked, style) + fmt.Fprintln(out, style.Accent("The explanation and completed objective are shown in the web companion.")) +} + +func printAchievements(out io.Writer, unlocked []profile.Achievement, style ui.Style) { + for _, achievement := range unlocked { + fmt.Fprintf(out, "%s %s — %s\n", style.Achievement("★ Achievement unlocked:"), achievement.Title, achievement.Description) + } +} diff --git a/internal/game/terminal_keys.go b/internal/game/terminal_keys.go new file mode 100644 index 0000000..32ffdff --- /dev/null +++ b/internal/game/terminal_keys.go @@ -0,0 +1,144 @@ +package game + +import ( + "bufio" + "io" +) + +const terminalKeyDeleteForward rune = '\ue000' + +var terminalSequenceReplacements = map[string]string{ + "\x1bOH": "\x1b[H", // application-mode Home + "\x1bOF": "\x1b[F", // application-mode End + "\x1b[1~": "\x1b[H", // alternate Home + "\x1b[4~": "\x1b[F", // alternate End + "\x1b[7~": "\x1b[H", // rxvt Home + "\x1b[8~": "\x1b[F", // rxvt End + "\x1bb": "\x1b[1;3D", // Meta/Option-B + "\x1bf": "\x1b[1;3C", // Meta/Option-F + "\x1b[1;5D": "\x1b[1;3D", // Ctrl-Left + "\x1b[1;5C": "\x1b[1;3C", // Ctrl-Right + "\x1b[1;9D": "\x1b[H", // Command/Meta-Left + "\x1b[1;9C": "\x1b[F", // Command/Meta-Right + "\x1b[1;10D": "\x1b[H", // Shift-Command/Meta-Left + "\x1b[1;10C": "\x1b[F", // Shift-Command/Meta-Right + "\x1b[3~": string(terminalKeyDeleteForward), + "\x1b\x7f": "\x17", // Option-Backspace to Ctrl-W + "\x1b\x08": "\x17", // alternate Option-Backspace +} + +type terminalReadWriter struct { + reader io.Reader + writer io.Writer +} + +// terminalKeyReader normalizes common terminal-specific key encodings into +// the VT100/readline subset understood by x/term. It never interprets bytes +// inside bracketed paste markers. +type terminalKeyReader struct { + source *bufio.Reader + pending []byte + deferredErr error + pasteActive bool + modalEscape bool +} + +func newTerminalKeyReader(reader io.Reader) *terminalKeyReader { + return &terminalKeyReader{source: bufio.NewReader(reader)} +} + +func (r *terminalKeyReader) Read(buffer []byte) (int, error) { + if len(buffer) == 0 { + return 0, nil + } + if len(r.pending) == 0 { + if r.deferredErr != nil { + err := r.deferredErr + r.deferredErr = nil + return 0, err + } + sequence, err := r.readSequence() + if len(sequence) == 0 { + return 0, err + } + if err != nil { + r.deferredErr = err + } + r.pending = r.normalize(sequence) + } + + count := copy(buffer, r.pending) + r.pending = r.pending[count:] + return count, nil +} + +func (r *terminalKeyReader) readSequence() ([]byte, error) { + first, err := r.source.ReadByte() + if err != nil { + return nil, err + } + sequence := []byte{first} + if first != '\x1b' { + return sequence, nil + } + // A standalone Escape key is meaningful to modal editors. ReadByte fills + // the source buffer with bytes already delivered by the terminal, so a + // complete arrow/CSI sequence remains available here while a lone Escape + // can return immediately instead of waiting for the next keypress. + if r.modalEscape && r.source.Buffered() == 0 { + return sequence, nil + } + + second, err := r.source.ReadByte() + if err != nil { + return sequence, err + } + sequence = append(sequence, second) + if second != '[' && second != 'O' { + return sequence, nil + } + + for len(sequence) < 32 { + next, readErr := r.source.ReadByte() + if readErr != nil { + return sequence, readErr + } + sequence = append(sequence, next) + if next >= 0x40 && next <= 0x7e { + break + } + } + return sequence, nil +} + +func (r *terminalKeyReader) buffered() int { return len(r.pending) + r.source.Buffered() } + +func (r *terminalKeyReader) unread(data []byte) { + pending := make([]byte, 0, len(data)+len(r.pending)) + pending = append(pending, data...) + pending = append(pending, r.pending...) + r.pending = pending +} + +func (r *terminalKeyReader) normalize(sequence []byte) []byte { + value := string(sequence) + if value == bracketedPasteStart { + r.pasteActive = true + return sequence + } + if value == bracketedPasteEnd && r.pasteActive { + r.pasteActive = false + return sequence + } + if r.pasteActive { + return sequence + } + if replacement, exists := terminalSequenceReplacements[value]; exists { + return []byte(replacement) + } + return sequence +} + +func (rw terminalReadWriter) Read(buffer []byte) (int, error) { return rw.reader.Read(buffer) } + +func (rw terminalReadWriter) Write(buffer []byte) (int, error) { return rw.writer.Write(buffer) } diff --git a/internal/game/vi.go b/internal/game/vi.go index 3d8c3ec..a56534d 100644 --- a/internal/game/vi.go +++ b/internal/game/vi.go @@ -4,10 +4,6 @@ import ( "errors" "fmt" "io" - "strconv" - "strings" - "unicode" - "unicode/utf8" "github.com/aleksandergregersen/opsquest/internal/sandbox" ) @@ -21,409 +17,6 @@ var ( type viSaveFunc func(path, content string) error -type viMode uint8 - -const ( - viNormalMode viMode = iota - viInsertMode - viCommandMode -) - -type viKeyKind uint8 - -const ( - viRuneKey viKeyKind = iota - viEscapeKey - viEnterKey - viBackspaceKey - viDeleteKey - viUpKey - viDownKey - viLeftKey - viRightKey - viHomeKey - viEndKey - viInterruptKey - viPasteStartKey - viPasteEndKey - viUnknownKey -) - -type viKey struct { - kind viKeyKind - rune rune - raw string -} - -type viEditor struct { - request sandbox.EditorRequest - lines [][]rune - row int - column int - preferredCol int - mode viMode - command []rune - pendingD bool - dirty bool - quit bool - message string - byteSize int -} - -func newViEditor(request sandbox.EditorRequest) (*viEditor, error) { - if len(request.Content) > maxViFileBytes { - return nil, fmt.Errorf("%w: %s exceeds the %d KiB teaching-editor limit", ErrUnsupportedEditorFile, request.DisplayPath, maxViFileBytes/1024) - } - if !utf8.ValidString(request.Content) { - return nil, fmt.Errorf("%w: %s is not valid UTF-8 text", ErrUnsupportedEditorFile, request.DisplayPath) - } - parts := strings.Split(request.Content, "\n") - lines := make([][]rune, len(parts)) - for index, line := range parts { - lines[index] = []rune(line) - } - return &viEditor{ - request: request, - lines: lines, - message: "NORMAL — i insert · h/j/k/l move · x delete · dd delete line · : commands", - byteSize: len(request.Content), - }, nil -} - -func (e *viEditor) content() string { - parts := make([]string, len(e.lines)) - for index, line := range e.lines { - parts[index] = string(line) - } - return strings.Join(parts, "\n") -} - -func (e *viEditor) handle(key viKey, save viSaveFunc) { - switch e.mode { - case viInsertMode: - e.handleInsert(key) - case viCommandMode: - e.handleCommand(key, save) - default: - e.handleNormal(key) - } -} - -func (e *viEditor) handleNormal(key viKey) { - e.message = "" - if key.kind == viInterruptKey || key.kind == viEscapeKey { - e.pendingD = false - return - } - - if key.kind != viRuneKey || key.rune != 'd' { - e.pendingD = false - } - switch key.kind { - case viLeftKey: - e.moveHorizontal(-1) - return - case viRightKey: - e.moveHorizontal(1) - return - case viUpKey: - e.moveVertical(-1) - return - case viDownKey: - e.moveVertical(1) - return - case viHomeKey: - e.column = 0 - e.preferredCol = 0 - return - case viEndKey: - e.column = e.normalLineEnd() - e.preferredCol = e.column - return - case viUnknownKey: - e.message = fmt.Sprintf("Unsupported key sequence %s", strconv.QuoteToASCII(key.raw)) - return - case viRuneKey: - default: - return - } - - switch key.rune { - case 'h': - e.moveHorizontal(-1) - case 'j': - e.moveVertical(1) - case 'k': - e.moveVertical(-1) - case 'l': - e.moveHorizontal(1) - case 'i': - e.mode = viInsertMode - e.pendingD = false - case 'x': - e.deleteAtCursor() - case 'd': - if e.pendingD { - e.deleteLine() - e.pendingD = false - } else { - e.pendingD = true - e.message = "d" - } - case ':': - e.mode = viCommandMode - e.command = nil - e.pendingD = false - default: - if unicode.IsPrint(key.rune) { - e.message = fmt.Sprintf("%q is outside this vi teaching subset", key.rune) - } - } -} - -func (e *viEditor) handleInsert(key viKey) { - switch key.kind { - case viEscapeKey, viInterruptKey: - e.mode = viNormalMode - if e.column > 0 { - e.column-- - } - e.clampNormalColumn() - e.preferredCol = e.column - e.message = "" - case viLeftKey: - if e.column > 0 { - e.column-- - } - e.preferredCol = e.column - case viRightKey: - if e.column < len(e.lines[e.row]) { - e.column++ - } - e.preferredCol = e.column - case viUpKey: - e.moveInsertVertical(-1) - case viDownKey: - e.moveInsertVertical(1) - case viHomeKey: - e.column = 0 - e.preferredCol = 0 - case viEndKey: - e.column = len(e.lines[e.row]) - e.preferredCol = e.column - case viBackspaceKey: - e.insertBackspace() - case viDeleteKey: - e.insertDelete() - case viEnterKey: - if !e.canGrow(1) { - return - } - line := e.lines[e.row] - left := append([]rune(nil), line[:e.column]...) - right := append([]rune(nil), line[e.column:]...) - e.lines[e.row] = left - e.lines = append(e.lines, nil) - copy(e.lines[e.row+2:], e.lines[e.row+1:]) - e.lines[e.row+1] = right - e.row++ - e.column = 0 - e.preferredCol = 0 - e.byteSize++ - e.dirty = true - case viRuneKey: - if key.rune < ' ' && key.rune != '\t' { - e.message = "That control character is not supported in insert mode" - return - } - growth := utf8.RuneLen(key.rune) - if growth < 0 || !e.canGrow(growth) { - return - } - line := e.lines[e.row] - line = append(line, 0) - copy(line[e.column+1:], line[e.column:]) - line[e.column] = key.rune - e.lines[e.row] = line - e.column++ - e.preferredCol = e.column - e.byteSize += growth - e.dirty = true - case viUnknownKey: - e.message = fmt.Sprintf("Unsupported key sequence %s", strconv.QuoteToASCII(key.raw)) - } -} - -func (e *viEditor) insertBackspace() { - if e.column > 0 { - line := e.lines[e.row] - removed := line[e.column-1] - e.lines[e.row] = append(line[:e.column-1], line[e.column:]...) - e.column-- - e.preferredCol = e.column - e.byteSize -= utf8.RuneLen(removed) - e.dirty = true - return - } - if e.row == 0 { - return - } - previousLength := len(e.lines[e.row-1]) - e.lines[e.row-1] = append(e.lines[e.row-1], e.lines[e.row]...) - e.lines = append(e.lines[:e.row], e.lines[e.row+1:]...) - e.row-- - e.column = previousLength - e.preferredCol = e.column - e.byteSize-- - e.dirty = true -} - -func (e *viEditor) insertDelete() { - e.removeRuneAtCursor() -} - -func (e *viEditor) handleCommand(key viKey, save viSaveFunc) { - switch key.kind { - case viEscapeKey, viInterruptKey: - e.mode = viNormalMode - e.command = nil - e.message = "" - case viBackspaceKey: - if len(e.command) == 0 { - e.mode = viNormalMode - return - } - e.command = e.command[:len(e.command)-1] - case viEnterKey: - e.executeCommand(save) - case viRuneKey: - if unicode.IsPrint(key.rune) { - e.command = append(e.command, key.rune) - } - } -} - -func (e *viEditor) executeCommand(save viSaveFunc) { - command := string(e.command) - e.command = nil - e.mode = viNormalMode - switch command { - case "w": - e.write(save) - case "q": - if e.dirty { - e.message = "E37: No write since last change (use :q! to discard)" - return - } - e.quit = true - case "wq": - if e.write(save) { - e.quit = true - } - case "q!": - e.quit = true - case "": - e.message = "" - default: - e.message = fmt.Sprintf("E492: Not an editor command: %s", command) - } -} - -func (e *viEditor) write(save viSaveFunc) bool { - content := e.content() - if err := save(e.request.Path, content); err != nil { - e.message = fmt.Sprintf("E212: cannot write %s: %v", e.request.DisplayPath, err) - return false - } - e.dirty = false - e.byteSize = len(content) - lineCount := len(e.lines) - if lineCount > 1 && strings.HasSuffix(content, "\n") { - lineCount-- - } - e.message = fmt.Sprintf("%q %dL, %dB written", e.request.DisplayPath, lineCount, e.byteSize) - return true -} - -func (e *viEditor) moveHorizontal(delta int) { - e.column = min(max(e.column+delta, 0), e.normalLineEnd()) - e.preferredCol = e.column -} - -func (e *viEditor) moveVertical(delta int) { - e.moveRow(delta) - e.clampNormalColumn() -} - -func (e *viEditor) moveInsertVertical(delta int) { - e.moveRow(delta) - e.column = min(e.column, len(e.lines[e.row])) -} - -func (e *viEditor) moveRow(delta int) { - e.row = min(max(e.row+delta, 0), len(e.lines)-1) - e.column = e.preferredCol -} - -func (e *viEditor) normalLineEnd() int { - if len(e.lines[e.row]) == 0 { - return 0 - } - return len(e.lines[e.row]) - 1 -} - -func (e *viEditor) clampNormalColumn() { - e.column = min(max(e.column, 0), e.normalLineEnd()) -} - -func (e *viEditor) deleteAtCursor() { - if !e.removeRuneAtCursor() { - return - } - e.clampNormalColumn() - e.preferredCol = e.column -} - -func (e *viEditor) removeRuneAtCursor() bool { - line := e.lines[e.row] - if e.column >= len(line) { - return false - } - removed := line[e.column] - e.lines[e.row] = append(line[:e.column], line[e.column+1:]...) - e.byteSize -= utf8.RuneLen(removed) - e.dirty = true - return true -} - -func (e *viEditor) deleteLine() { - before := e.byteSize - if len(e.lines) == 1 { - e.lines[0] = nil - e.row = 0 - e.column = 0 - } else { - e.lines = append(e.lines[:e.row], e.lines[e.row+1:]...) - if e.row >= len(e.lines) { - e.row = len(e.lines) - 1 - } - e.clampNormalColumn() - } - e.preferredCol = e.column - e.byteSize = len(e.content()) - if e.byteSize != before { - e.dirty = true - } -} - -func (e *viEditor) canGrow(bytes int) bool { - if e.byteSize+bytes <= maxViFileBytes { - return true - } - e.message = fmt.Sprintf("E340: editor limit is %d KiB", maxViFileBytes/1024) - return false -} - type viSizeFunc func() (width, height int) func runViEditor(input *terminalKeyReader, output io.Writer, request sandbox.EditorRequest, save viSaveFunc, size viSizeFunc) error { @@ -472,300 +65,3 @@ func runViEditor(input *terminalKeyReader, output io.Writer, request sandbox.Edi editor.handle(key, save) } } - -func readViPasteKey(input *terminalKeyReader) (viKey, error) { - first, err := readViByte(input) - if err != nil { - return viKey{}, err - } - // terminalKeyReader toggles pasteActive only after reading the complete end - // marker, which lets this literal reader distinguish it from an Escape byte - // contained in pasted text. - if first == '\x1b' && !input.pasteActive { - marker := make([]byte, len(bracketedPasteEnd)) - marker[0] = first - for index := 1; index < len(marker); index++ { - marker[index], err = readViByte(input) - if err != nil { - return viKey{}, err - } - } - if string(marker) == bracketedPasteEnd { - return viKey{kind: viPasteEndKey}, nil - } - return viKey{kind: viUnknownKey, raw: string(marker)}, nil - } - if first == '\r' || first == '\n' { - return viKey{kind: viEnterKey}, nil - } - if first < utf8.RuneSelf { - return viKey{kind: viRuneKey, rune: rune(first)}, nil - } - return readViUTF8Key(input, first) -} - -func readViKey(input *terminalKeyReader) (viKey, error) { - first, err := readViByte(input) - if err != nil { - return viKey{}, err - } - switch first { - case '\x1b': - if input.buffered() == 0 { - return viKey{kind: viEscapeKey}, nil - } - second, err := readViByte(input) - if err != nil { - return viKey{kind: viEscapeKey}, nil - } - if second != '[' && second != 'O' { - input.unread([]byte{second}) - return viKey{kind: viEscapeKey}, nil - } - sequence := []byte{'\x1b', second} - for len(sequence) < 32 { - next, err := readViByte(input) - if err != nil { - return viKey{kind: viUnknownKey, raw: string(sequence)}, nil - } - sequence = append(sequence, next) - if next >= 0x40 && next <= 0x7e { - break - } - } - return decodeViSequence(string(sequence)), nil - case '\r', '\n': - return viKey{kind: viEnterKey}, nil - case '\x7f', '\b': - return viKey{kind: viBackspaceKey}, nil - case '\x03': - return viKey{kind: viInterruptKey}, nil - } - - if first < utf8.RuneSelf { - return viKey{kind: viRuneKey, rune: rune(first)}, nil - } - return readViUTF8Key(input, first) -} - -func readViUTF8Key(input *terminalKeyReader, first byte) (viKey, error) { - width := utf8SequenceLength(first) - if width == 0 { - return viKey{kind: viRuneKey, rune: utf8.RuneError}, nil - } - encoded := make([]byte, width) - encoded[0] = first - for index := 1; index < width; index++ { - next, err := readViByte(input) - if err != nil { - return viKey{}, err - } - encoded[index] = next - } - decoded, decodedWidth := utf8.DecodeRune(encoded) - if decoded == utf8.RuneError && decodedWidth == 1 { - return viKey{kind: viRuneKey, rune: utf8.RuneError}, nil - } - if decoded == terminalKeyDeleteForward { - return viKey{kind: viDeleteKey}, nil - } - return viKey{kind: viRuneKey, rune: decoded}, nil -} - -func readViByte(input io.Reader) (byte, error) { - var value [1]byte - _, err := io.ReadFull(input, value[:]) - return value[0], err -} - -func utf8SequenceLength(first byte) int { - switch { - case first&0xe0 == 0xc0: - return 2 - case first&0xf0 == 0xe0: - return 3 - case first&0xf8 == 0xf0: - return 4 - default: - return 0 - } -} - -var viSequenceKinds = map[string]viKeyKind{ - bracketedPasteStart: viPasteStartKey, - bracketedPasteEnd: viPasteEndKey, - "\x1b[A": viUpKey, - "\x1bOA": viUpKey, - "\x1b[B": viDownKey, - "\x1bOB": viDownKey, - "\x1b[C": viRightKey, - "\x1bOC": viRightKey, - "\x1b[D": viLeftKey, - "\x1bOD": viLeftKey, - "\x1b[H": viHomeKey, - "\x1bOH": viHomeKey, - "\x1b[F": viEndKey, - "\x1bOF": viEndKey, - "\x1b[3~": viDeleteKey, -} - -func decodeViSequence(sequence string) viKey { - if kind, found := viSequenceKinds[sequence]; found { - return viKey{kind: kind} - } - return viKey{kind: viUnknownKey, raw: sequence} -} - -func renderViEditor(output io.Writer, editor *viEditor, width, height int) error { - width = max(width, 20) - height = max(height, 6) - bodyRows := height - 2 - top := 0 - if editor.row >= bodyRows { - top = editor.row - bodyRows + 1 - } - cursorDisplayColumn := displayViColumn(editor.lines[editor.row], editor.column) - horizontalOffset := 0 - if cursorDisplayColumn >= width { - horizontalOffset = cursorDisplayColumn - width + 1 - } - - var screen strings.Builder - screen.WriteString("\x1b[?25l\x1b[H") - for screenRow := 0; screenRow < bodyRows; screenRow++ { - lineIndex := top + screenRow - line := "~" - if lineIndex < len(editor.lines) { - line = displayViWindow(editor.lines[lineIndex], horizontalOffset, width) - } - screen.WriteString(padViLine(line, width)) - screen.WriteString("\r\n") - } - status := editor.statusLine() - screen.WriteString("\x1b[7m") - screen.WriteString(padViLine(status, width)) - screen.WriteString("\x1b[0m\r\n") - cheatSheet := "h/j/k/l or arrows move · i insert · Esc normal · x delete · dd line · :w :q :wq" - screen.WriteString(padViLine(cheatSheet, width)) - - cursorRow := editor.row - top + 1 - cursorColumn := cursorDisplayColumn - horizontalOffset + 1 - if editor.mode == viCommandMode { - cursorRow = bodyRows + 1 - cursorColumn = len([]rune(":"+string(editor.command))) + 1 - cursorColumn = min(cursorColumn, width) - } - fmt.Fprintf(&screen, "\x1b[%d;%dH\x1b[?25h", cursorRow, cursorColumn) - _, err := io.WriteString(output, screen.String()) - return err -} - -func (e *viEditor) statusLine() string { - if e.mode == viCommandMode { - return ":" + string(e.command) - } - if e.message != "" { - return sanitizeViText(e.message) - } - modified := "" - if e.dirty { - modified = " [+]" - } - mode := "NORMAL" - if e.mode == viInsertMode { - mode = "-- INSERT --" - } - return fmt.Sprintf("%s%s %s %d,%d", sanitizeViText(e.request.DisplayPath), modified, mode, e.row+1, e.column+1) -} - -func displayViColumn(line []rune, column int) int { - column = min(column, len(line)) - columns := 0 - for _, char := range line[:column] { - columns += displayViRuneWidth(char, columns) - } - return columns -} - -func displayViWindow(line []rune, offset, width int) string { - offset = max(offset, 0) - if width <= 0 { - return "" - } - var displayed strings.Builder - displayed.Grow(width) - columns := 0 - written := 0 - for _, char := range line { - segmentWidth := displayViRuneWidth(char, columns) - if columns+segmentWidth <= offset { - columns += segmentWidth - continue - } - if written >= width { - break - } - start := 0 - if offset > columns { - start = offset - columns - } - available := segmentWidth - start - if available > width-written { - available = width - written - } - if char != '\t' && !unicode.IsControl(char) { - if start == 0 && available == 1 { - displayed.WriteRune(char) - } - } else { - text := displayViRune(char, columns) - segment := []rune(text) - displayed.WriteString(string(segment[start : start+available])) - } - written += available - columns += segmentWidth - } - return displayed.String() -} - -func displayViRuneWidth(char rune, column int) int { - if char == '\t' { - return 8 - column%8 - } - if unicode.IsControl(char) && char < 0x20 { - return 2 - } - return 1 -} - -func displayViRune(char rune, column int) string { - if char == '\t' { - spaces := 8 - column%8 - return strings.Repeat(" ", spaces) - } - if unicode.IsControl(char) { - if char < 0x20 { - return "^" + string(char+'@') - } - return "?" - } - return string(char) -} - -func sanitizeViText(value string) string { - var sanitized strings.Builder - for _, char := range value { - if unicode.IsControl(char) { - sanitized.WriteString(displayViRune(char, 0)) - continue - } - sanitized.WriteRune(char) - } - return sanitized.String() -} - -func padViLine(value string, width int) string { - runes := []rune(value) - runes = runes[:min(len(runes), width)] - return string(runes) + strings.Repeat(" ", width-len(runes)) -} diff --git a/internal/game/vi_editor.go b/internal/game/vi_editor.go new file mode 100644 index 0000000..9ae5682 --- /dev/null +++ b/internal/game/vi_editor.go @@ -0,0 +1,388 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/aleksandergregersen/opsquest/internal/sandbox" +) + +type viMode uint8 + +const ( + viNormalMode viMode = iota + viInsertMode + viCommandMode +) + +type viEditor struct { + request sandbox.EditorRequest + lines [][]rune + row int + column int + preferredCol int + mode viMode + command []rune + pendingD bool + dirty bool + quit bool + message string + byteSize int +} + +func newViEditor(request sandbox.EditorRequest) (*viEditor, error) { + if len(request.Content) > maxViFileBytes { + return nil, fmt.Errorf("%w: %s exceeds the %d KiB teaching-editor limit", ErrUnsupportedEditorFile, request.DisplayPath, maxViFileBytes/1024) + } + if !utf8.ValidString(request.Content) { + return nil, fmt.Errorf("%w: %s is not valid UTF-8 text", ErrUnsupportedEditorFile, request.DisplayPath) + } + parts := strings.Split(request.Content, "\n") + lines := make([][]rune, len(parts)) + for index, line := range parts { + lines[index] = []rune(line) + } + return &viEditor{ + request: request, + lines: lines, + message: "NORMAL — i insert · h/j/k/l move · x delete · dd delete line · : commands", + byteSize: len(request.Content), + }, nil +} + +func (e *viEditor) content() string { + parts := make([]string, len(e.lines)) + for index, line := range e.lines { + parts[index] = string(line) + } + return strings.Join(parts, "\n") +} + +func (e *viEditor) handle(key viKey, save viSaveFunc) { + switch e.mode { + case viInsertMode: + e.handleInsert(key) + case viCommandMode: + e.handleCommand(key, save) + default: + e.handleNormal(key) + } +} + +func (e *viEditor) handleNormal(key viKey) { + e.message = "" + if key.kind == viInterruptKey || key.kind == viEscapeKey { + e.pendingD = false + return + } + + if key.kind != viRuneKey || key.rune != 'd' { + e.pendingD = false + } + switch key.kind { + case viLeftKey: + e.moveHorizontal(-1) + return + case viRightKey: + e.moveHorizontal(1) + return + case viUpKey: + e.moveVertical(-1) + return + case viDownKey: + e.moveVertical(1) + return + case viHomeKey: + e.column = 0 + e.preferredCol = 0 + return + case viEndKey: + e.column = e.normalLineEnd() + e.preferredCol = e.column + return + case viUnknownKey: + e.message = fmt.Sprintf("Unsupported key sequence %s", strconv.QuoteToASCII(key.raw)) + return + case viRuneKey: + default: + return + } + + switch key.rune { + case 'h': + e.moveHorizontal(-1) + case 'j': + e.moveVertical(1) + case 'k': + e.moveVertical(-1) + case 'l': + e.moveHorizontal(1) + case 'i': + e.mode = viInsertMode + e.pendingD = false + case 'x': + e.deleteAtCursor() + case 'd': + if e.pendingD { + e.deleteLine() + e.pendingD = false + } else { + e.pendingD = true + e.message = "d" + } + case ':': + e.mode = viCommandMode + e.command = nil + e.pendingD = false + default: + if unicode.IsPrint(key.rune) { + e.message = fmt.Sprintf("%q is outside this vi teaching subset", key.rune) + } + } +} + +func (e *viEditor) handleInsert(key viKey) { + switch key.kind { + case viEscapeKey, viInterruptKey: + e.mode = viNormalMode + if e.column > 0 { + e.column-- + } + e.clampNormalColumn() + e.preferredCol = e.column + e.message = "" + case viLeftKey: + if e.column > 0 { + e.column-- + } + e.preferredCol = e.column + case viRightKey: + if e.column < len(e.lines[e.row]) { + e.column++ + } + e.preferredCol = e.column + case viUpKey: + e.moveInsertVertical(-1) + case viDownKey: + e.moveInsertVertical(1) + case viHomeKey: + e.column = 0 + e.preferredCol = 0 + case viEndKey: + e.column = len(e.lines[e.row]) + e.preferredCol = e.column + case viBackspaceKey: + e.insertBackspace() + case viDeleteKey: + e.insertDelete() + case viEnterKey: + if !e.canGrow(1) { + return + } + line := e.lines[e.row] + left := append([]rune(nil), line[:e.column]...) + right := append([]rune(nil), line[e.column:]...) + e.lines[e.row] = left + e.lines = append(e.lines, nil) + copy(e.lines[e.row+2:], e.lines[e.row+1:]) + e.lines[e.row+1] = right + e.row++ + e.column = 0 + e.preferredCol = 0 + e.byteSize++ + e.dirty = true + case viRuneKey: + if key.rune < ' ' && key.rune != '\t' { + e.message = "That control character is not supported in insert mode" + return + } + growth := utf8.RuneLen(key.rune) + if growth < 0 || !e.canGrow(growth) { + return + } + line := e.lines[e.row] + line = append(line, 0) + copy(line[e.column+1:], line[e.column:]) + line[e.column] = key.rune + e.lines[e.row] = line + e.column++ + e.preferredCol = e.column + e.byteSize += growth + e.dirty = true + case viUnknownKey: + e.message = fmt.Sprintf("Unsupported key sequence %s", strconv.QuoteToASCII(key.raw)) + } +} + +func (e *viEditor) insertBackspace() { + if e.column > 0 { + line := e.lines[e.row] + removed := line[e.column-1] + e.lines[e.row] = append(line[:e.column-1], line[e.column:]...) + e.column-- + e.preferredCol = e.column + e.byteSize -= utf8.RuneLen(removed) + e.dirty = true + return + } + if e.row == 0 { + return + } + previousLength := len(e.lines[e.row-1]) + e.lines[e.row-1] = append(e.lines[e.row-1], e.lines[e.row]...) + e.lines = append(e.lines[:e.row], e.lines[e.row+1:]...) + e.row-- + e.column = previousLength + e.preferredCol = e.column + e.byteSize-- + e.dirty = true +} + +func (e *viEditor) insertDelete() { + e.removeRuneAtCursor() +} + +func (e *viEditor) handleCommand(key viKey, save viSaveFunc) { + switch key.kind { + case viEscapeKey, viInterruptKey: + e.mode = viNormalMode + e.command = nil + e.message = "" + case viBackspaceKey: + if len(e.command) == 0 { + e.mode = viNormalMode + return + } + e.command = e.command[:len(e.command)-1] + case viEnterKey: + e.executeCommand(save) + case viRuneKey: + if unicode.IsPrint(key.rune) { + e.command = append(e.command, key.rune) + } + } +} + +func (e *viEditor) executeCommand(save viSaveFunc) { + command := string(e.command) + e.command = nil + e.mode = viNormalMode + switch command { + case "w": + e.write(save) + case "q": + if e.dirty { + e.message = "E37: No write since last change (use :q! to discard)" + return + } + e.quit = true + case "wq": + if e.write(save) { + e.quit = true + } + case "q!": + e.quit = true + case "": + e.message = "" + default: + e.message = fmt.Sprintf("E492: Not an editor command: %s", command) + } +} + +func (e *viEditor) write(save viSaveFunc) bool { + content := e.content() + if err := save(e.request.Path, content); err != nil { + e.message = fmt.Sprintf("E212: cannot write %s: %v", e.request.DisplayPath, err) + return false + } + e.dirty = false + e.byteSize = len(content) + lineCount := len(e.lines) + if lineCount > 1 && strings.HasSuffix(content, "\n") { + lineCount-- + } + e.message = fmt.Sprintf("%q %dL, %dB written", e.request.DisplayPath, lineCount, e.byteSize) + return true +} + +func (e *viEditor) moveHorizontal(delta int) { + e.column = min(max(e.column+delta, 0), e.normalLineEnd()) + e.preferredCol = e.column +} + +func (e *viEditor) moveVertical(delta int) { + e.moveRow(delta) + e.clampNormalColumn() +} + +func (e *viEditor) moveInsertVertical(delta int) { + e.moveRow(delta) + e.column = min(e.column, len(e.lines[e.row])) +} + +func (e *viEditor) moveRow(delta int) { + e.row = min(max(e.row+delta, 0), len(e.lines)-1) + e.column = e.preferredCol +} + +func (e *viEditor) normalLineEnd() int { + if len(e.lines[e.row]) == 0 { + return 0 + } + return len(e.lines[e.row]) - 1 +} + +func (e *viEditor) clampNormalColumn() { + e.column = min(max(e.column, 0), e.normalLineEnd()) +} + +func (e *viEditor) deleteAtCursor() { + if !e.removeRuneAtCursor() { + return + } + e.clampNormalColumn() + e.preferredCol = e.column +} + +func (e *viEditor) removeRuneAtCursor() bool { + line := e.lines[e.row] + if e.column >= len(line) { + return false + } + removed := line[e.column] + e.lines[e.row] = append(line[:e.column], line[e.column+1:]...) + e.byteSize -= utf8.RuneLen(removed) + e.dirty = true + return true +} + +func (e *viEditor) deleteLine() { + before := e.byteSize + if len(e.lines) == 1 { + e.lines[0] = nil + e.row = 0 + e.column = 0 + } else { + e.lines = append(e.lines[:e.row], e.lines[e.row+1:]...) + if e.row >= len(e.lines) { + e.row = len(e.lines) - 1 + } + e.clampNormalColumn() + } + e.preferredCol = e.column + e.byteSize = len(e.content()) + if e.byteSize != before { + e.dirty = true + } +} + +func (e *viEditor) canGrow(bytes int) bool { + if e.byteSize+bytes <= maxViFileBytes { + return true + } + e.message = fmt.Sprintf("E340: editor limit is %d KiB", maxViFileBytes/1024) + return false +} diff --git a/internal/game/vi_keys.go b/internal/game/vi_keys.go new file mode 100644 index 0000000..ac6c1da --- /dev/null +++ b/internal/game/vi_keys.go @@ -0,0 +1,175 @@ +package game + +import ( + "io" + "unicode/utf8" +) + +type viKeyKind uint8 + +const ( + viRuneKey viKeyKind = iota + viEscapeKey + viEnterKey + viBackspaceKey + viDeleteKey + viUpKey + viDownKey + viLeftKey + viRightKey + viHomeKey + viEndKey + viInterruptKey + viPasteStartKey + viPasteEndKey + viUnknownKey +) + +type viKey struct { + kind viKeyKind + rune rune + raw string +} + +func readViPasteKey(input *terminalKeyReader) (viKey, error) { + first, err := readViByte(input) + if err != nil { + return viKey{}, err + } + // terminalKeyReader toggles pasteActive only after reading the complete end + // marker, which lets this literal reader distinguish it from an Escape byte + // contained in pasted text. + if first == '\x1b' && !input.pasteActive { + marker := make([]byte, len(bracketedPasteEnd)) + marker[0] = first + for index := 1; index < len(marker); index++ { + marker[index], err = readViByte(input) + if err != nil { + return viKey{}, err + } + } + if string(marker) == bracketedPasteEnd { + return viKey{kind: viPasteEndKey}, nil + } + return viKey{kind: viUnknownKey, raw: string(marker)}, nil + } + if first == '\r' || first == '\n' { + return viKey{kind: viEnterKey}, nil + } + if first < utf8.RuneSelf { + return viKey{kind: viRuneKey, rune: rune(first)}, nil + } + return readViUTF8Key(input, first) +} + +func readViKey(input *terminalKeyReader) (viKey, error) { + first, err := readViByte(input) + if err != nil { + return viKey{}, err + } + switch first { + case '\x1b': + if input.buffered() == 0 { + return viKey{kind: viEscapeKey}, nil + } + second, err := readViByte(input) + if err != nil { + return viKey{kind: viEscapeKey}, nil + } + if second != '[' && second != 'O' { + input.unread([]byte{second}) + return viKey{kind: viEscapeKey}, nil + } + sequence := []byte{'\x1b', second} + for len(sequence) < 32 { + next, err := readViByte(input) + if err != nil { + return viKey{kind: viUnknownKey, raw: string(sequence)}, nil + } + sequence = append(sequence, next) + if next >= 0x40 && next <= 0x7e { + break + } + } + return decodeViSequence(string(sequence)), nil + case '\r', '\n': + return viKey{kind: viEnterKey}, nil + case '\x7f', '\b': + return viKey{kind: viBackspaceKey}, nil + case '\x03': + return viKey{kind: viInterruptKey}, nil + } + + if first < utf8.RuneSelf { + return viKey{kind: viRuneKey, rune: rune(first)}, nil + } + return readViUTF8Key(input, first) +} + +func readViUTF8Key(input *terminalKeyReader, first byte) (viKey, error) { + width := utf8SequenceLength(first) + if width == 0 { + return viKey{kind: viRuneKey, rune: utf8.RuneError}, nil + } + encoded := make([]byte, width) + encoded[0] = first + for index := 1; index < width; index++ { + next, err := readViByte(input) + if err != nil { + return viKey{}, err + } + encoded[index] = next + } + decoded, decodedWidth := utf8.DecodeRune(encoded) + if decoded == utf8.RuneError && decodedWidth == 1 { + return viKey{kind: viRuneKey, rune: utf8.RuneError}, nil + } + if decoded == terminalKeyDeleteForward { + return viKey{kind: viDeleteKey}, nil + } + return viKey{kind: viRuneKey, rune: decoded}, nil +} + +func readViByte(input io.Reader) (byte, error) { + var value [1]byte + _, err := io.ReadFull(input, value[:]) + return value[0], err +} + +func utf8SequenceLength(first byte) int { + switch { + case first&0xe0 == 0xc0: + return 2 + case first&0xf0 == 0xe0: + return 3 + case first&0xf8 == 0xf0: + return 4 + default: + return 0 + } +} + +var viSequenceKinds = map[string]viKeyKind{ + bracketedPasteStart: viPasteStartKey, + bracketedPasteEnd: viPasteEndKey, + "\x1b[A": viUpKey, + "\x1bOA": viUpKey, + "\x1b[B": viDownKey, + "\x1bOB": viDownKey, + "\x1b[C": viRightKey, + "\x1bOC": viRightKey, + "\x1b[D": viLeftKey, + "\x1bOD": viLeftKey, + "\x1b[H": viHomeKey, + "\x1bOH": viHomeKey, + "\x1b[F": viEndKey, + "\x1bOF": viEndKey, + "\x1b[3~": viDeleteKey, +} + +func decodeViSequence(sequence string) viKey { + if kind, found := viSequenceKinds[sequence]; found { + return viKey{kind: kind} + } + return viKey{kind: viUnknownKey, raw: sequence} +} diff --git a/internal/game/vi_render.go b/internal/game/vi_render.go new file mode 100644 index 0000000..478aaf6 --- /dev/null +++ b/internal/game/vi_render.go @@ -0,0 +1,162 @@ +package game + +import ( + "fmt" + "io" + "strings" + "unicode" +) + +func renderViEditor(output io.Writer, editor *viEditor, width, height int) error { + width = max(width, 20) + height = max(height, 6) + bodyRows := height - 2 + top := 0 + if editor.row >= bodyRows { + top = editor.row - bodyRows + 1 + } + cursorDisplayColumn := displayViColumn(editor.lines[editor.row], editor.column) + horizontalOffset := 0 + if cursorDisplayColumn >= width { + horizontalOffset = cursorDisplayColumn - width + 1 + } + + var screen strings.Builder + screen.WriteString("\x1b[?25l\x1b[H") + for screenRow := 0; screenRow < bodyRows; screenRow++ { + lineIndex := top + screenRow + line := "~" + if lineIndex < len(editor.lines) { + line = displayViWindow(editor.lines[lineIndex], horizontalOffset, width) + } + screen.WriteString(padViLine(line, width)) + screen.WriteString("\r\n") + } + status := editor.statusLine() + screen.WriteString("\x1b[7m") + screen.WriteString(padViLine(status, width)) + screen.WriteString("\x1b[0m\r\n") + cheatSheet := "h/j/k/l or arrows move · i insert · Esc normal · x delete · dd line · :w :q :wq" + screen.WriteString(padViLine(cheatSheet, width)) + + cursorRow := editor.row - top + 1 + cursorColumn := cursorDisplayColumn - horizontalOffset + 1 + if editor.mode == viCommandMode { + cursorRow = bodyRows + 1 + cursorColumn = len([]rune(":"+string(editor.command))) + 1 + cursorColumn = min(cursorColumn, width) + } + fmt.Fprintf(&screen, "\x1b[%d;%dH\x1b[?25h", cursorRow, cursorColumn) + _, err := io.WriteString(output, screen.String()) + return err +} + +func (e *viEditor) statusLine() string { + if e.mode == viCommandMode { + return ":" + string(e.command) + } + if e.message != "" { + return sanitizeViText(e.message) + } + modified := "" + if e.dirty { + modified = " [+]" + } + mode := "NORMAL" + if e.mode == viInsertMode { + mode = "-- INSERT --" + } + return fmt.Sprintf("%s%s %s %d,%d", sanitizeViText(e.request.DisplayPath), modified, mode, e.row+1, e.column+1) +} + +func displayViColumn(line []rune, column int) int { + column = min(column, len(line)) + columns := 0 + for _, char := range line[:column] { + columns += displayViRuneWidth(char, columns) + } + return columns +} + +func displayViWindow(line []rune, offset, width int) string { + offset = max(offset, 0) + if width <= 0 { + return "" + } + var displayed strings.Builder + displayed.Grow(width) + columns := 0 + written := 0 + for _, char := range line { + segmentWidth := displayViRuneWidth(char, columns) + if columns+segmentWidth <= offset { + columns += segmentWidth + continue + } + if written >= width { + break + } + start := 0 + if offset > columns { + start = offset - columns + } + available := segmentWidth - start + if available > width-written { + available = width - written + } + if char != '\t' && !unicode.IsControl(char) { + if start == 0 && available == 1 { + displayed.WriteRune(char) + } + } else { + text := displayViRune(char, columns) + segment := []rune(text) + displayed.WriteString(string(segment[start : start+available])) + } + written += available + columns += segmentWidth + } + return displayed.String() +} + +func displayViRuneWidth(char rune, column int) int { + if char == '\t' { + return 8 - column%8 + } + if unicode.IsControl(char) && char < 0x20 { + return 2 + } + return 1 +} + +func displayViRune(char rune, column int) string { + if char == '\t' { + spaces := 8 - column%8 + return strings.Repeat(" ", spaces) + } + if unicode.IsControl(char) { + if char < 0x20 { + return "^" + string(char+'@') + } + return "?" + } + return string(char) +} + +func sanitizeViText(value string) string { + var sanitized strings.Builder + for _, char := range value { + if unicode.IsControl(char) { + sanitized.WriteString(displayViRune(char, 0)) + continue + } + sanitized.WriteRune(char) + } + return sanitized.String() +} + +func padViLine(value string, width int) string { + runes := []rune(value) + runes = runes[:min(len(runes), width)] + return string(runes) + strings.Repeat(" ", width-len(runes)) +} diff --git a/internal/game/vi_render_test.go b/internal/game/vi_render_test.go new file mode 100644 index 0000000..b6931ff --- /dev/null +++ b/internal/game/vi_render_test.go @@ -0,0 +1,102 @@ +package game + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/sandbox" +) + +func TestViRendererSanitizesFileAndStatusControlBytes(t *testing.T) { + editor, err := newViEditor(sandbox.EditorRequest{ + Path: "/work/file", + DisplayPath: "bad\x1bname", + Content: "safe\x1b[31m\tend CLIPPED_MARKER", + }) + if err != nil { + t.Fatal(err) + } + editor.message = "" + output := &bytes.Buffer{} + if err := renderViEditor(output, editor, 20, 6); err != nil { + t.Fatal(err) + } + rendered := output.String() + if strings.Contains(rendered, "safe\x1b[31m") || strings.Contains(rendered, "bad\x1bname") { + t.Fatalf("rendered virtual controls verbatim: %q", rendered) + } + if !strings.Contains(rendered, "safe^[[31m") || !strings.Contains(rendered, "bad^[name") { + t.Fatalf("rendered content was not visibly sanitized: %q", rendered) + } + if strings.Contains(rendered, "CLIPPED_MARKER") { + t.Fatalf("rendered line was not clipped to the viewport: %q", rendered) + } +} + +func TestViRendererScrollsHorizontallyWithTheCursor(t *testing.T) { + editor := viTestEditor(t, "0123456789abcdefghijklmnopqrstuv") + editor.column = len(editor.lines[0]) - 1 + editor.preferredCol = editor.column + output := &bytes.Buffer{} + if err := renderViEditor(output, editor, 20, 6); err != nil { + t.Fatal(err) + } + rendered := output.String() + if strings.Contains(rendered, "0123456789") || !strings.Contains(rendered, "klmnopqrstuv") { + t.Fatalf("horizontal viewport did not follow the cursor: %q", rendered) + } + if !strings.Contains(rendered, "\x1b[1;20H") { + t.Fatalf("cursor was not placed at the visible right edge: %q", rendered) + } +} + +func TestDisplayViWindowClipsWithoutLosingSpecialCharacterColumns(t *testing.T) { + tests := []struct { + name string + line string + offset int + width int + want string + }{ + {name: "plain", line: "abcdef", offset: 2, width: 3, want: "cde"}, + {name: "inside tab", line: "a\tb", offset: 6, width: 4, want: " b"}, + {name: "control notation", line: "x\x1by", offset: 1, width: 3, want: "^[y"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := displayViWindow([]rune(test.line), test.offset, test.width); got != test.want { + t.Fatalf("displayViWindow() = %q, want %q", got, test.want) + } + }) + } +} + +func BenchmarkRenderViLongLine(b *testing.B) { + editor, err := newViEditor(sandbox.EditorRequest{ + Path: "/work/long.txt", + DisplayPath: "long.txt", + Content: strings.Repeat("x", maxViFileBytes), + }) + if err != nil { + b.Fatal(err) + } + for _, benchmark := range []struct { + name string + column int + }{ + {name: "cursor-start", column: 0}, + {name: "cursor-end", column: len(editor.lines[0]) - 1}, + } { + b.Run(benchmark.name, func(b *testing.B) { + editor.column = benchmark.column + b.ReportAllocs() + for range b.N { + if err := renderViEditor(io.Discard, editor, 80, 24); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/internal/game/vi_session_test.go b/internal/game/vi_session_test.go new file mode 100644 index 0000000..6126e9c --- /dev/null +++ b/internal/game/vi_session_test.go @@ -0,0 +1,147 @@ +package game + +import ( + "bytes" + "fmt" + "io" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" + "github.com/aleksandergregersen/opsquest/internal/sandbox" +) + +func TestNonTerminalViRefusalDoesNotConsumeTheNextCommand(t *testing.T) { + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("linux-config-surgery") + player := profile.New("tester") + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + session := Session{ + Mission: item, Player: &player, Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + In: strings.NewReader("vi app.env\nquit\n"), Out: out, ErrOut: errOut, Catalog: catalog, + } + result, err := session.Run() + if err != nil { + t.Fatal(err) + } + if !result.Quit || player.Commands["vi"] != 0 { + t.Fatalf("result = %#v, vi mastery = %d", result, player.Commands["vi"]) + } + if !strings.Contains(errOut.String(), "vi: interactive editor requires a terminal") { + t.Fatalf("stderr = %q", errOut.String()) + } + if !strings.Contains(out.String(), "Mission paused") { + t.Fatalf("following quit command was not consumed: %q", out.String()) + } +} + +type fakeViReader struct { + lines []string + index int + edit func(sandbox.EditorRequest, viSaveFunc) error +} + +func (r *fakeViReader) ReadLine(_ string, _ CompletionSource) (string, error) { + if r.index >= len(r.lines) { + return "", io.EOF + } + line := r.lines[r.index] + r.index++ + return line, nil +} + +func (r *fakeViReader) Edit(request sandbox.EditorRequest, save viSaveFunc) error { + return r.edit(request, save) +} + +func TestViSessionCompletesOutcomeAndRecordsMastery(t *testing.T) { + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("linux-config-surgery") + player := profile.New("tester") + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester") + reader := &fakeViReader{ + lines: []string{"vi app.env"}, + edit: func(request sandbox.EditorRequest, save viSaveFunc) error { + if request.Path != "/etc/byteworks/app.env" || !strings.Contains(request.Content, "LOG_LEVEL=debug") { + return fmt.Errorf("unexpected editor request: %#v", request) + } + return save(request.Path, strings.Replace(request.Content, "LOG_LEVEL=debug", "LOG_LEVEL=info", 1)) + }, + } + out := &bytes.Buffer{} + session := Session{ + Mission: item, Player: &player, Saver: store, + Out: out, ErrOut: &bytes.Buffer{}, Reader: reader, Catalog: catalog, + Now: func() time.Time { return time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) }, + } + result, err := session.Run() + if err != nil { + t.Fatal(err) + } + if !result.Completed || player.Commands["vi"] != 1 || !player.IsComplete(item.ID) { + t.Fatalf("result = %#v, vi mastery = %d, completed = %v", result, player.Commands["vi"], player.IsComplete(item.ID)) + } + if !strings.Contains(out.String(), "Mission complete") || !strings.Contains(out.String(), "New command discovered: vi") { + t.Fatalf("session output = %q", out.String()) + } +} + +func TestModalFirstAidAcceptsViAndRejectsDiscardedChanges(t *testing.T) { + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, found := catalog.Find("linux-vi-first-aid") + if !found { + t.Fatal("Modal First Aid mission missing") + } + + for _, test := range []struct { + name string + keys string + complete bool + }{ + {name: "dd and write-quit", keys: "dd:wq\r", complete: true}, + {name: "dd and discard", keys: "dd:q!\r", complete: false}, + } { + t.Run(test.name, func(t *testing.T) { + box, err := sandbox.New(item.Setup, item.StartDir) + if err != nil { + t.Fatal(err) + } + result, err := box.Execute("vi release.env") + if err != nil { + t.Fatal(err) + } + if result.Editor == nil { + t.Fatal("vi did not return an editor request") + } + if err := runViEditor( + newTerminalKeyReader(strings.NewReader(test.keys)), + io.Discard, + *result.Editor, + box.SaveEditorFile, + func() (int, int) { return 80, 12 }, + ); err != nil { + t.Fatal(err) + } + complete, err := Validate(item.Validation, box, "") + if err != nil { + t.Fatal(err) + } + if complete != test.complete { + t.Fatalf("mission complete = %v, want %v", complete, test.complete) + } + }) + } +} diff --git a/internal/game/vi_test.go b/internal/game/vi_test.go index fe9158e..f01b2be 100644 --- a/internal/game/vi_test.go +++ b/internal/game/vi_test.go @@ -3,15 +3,9 @@ package game import ( "bytes" "errors" - "fmt" - "io" - "path/filepath" "strings" "testing" - "time" - "github.com/aleksandergregersen/opsquest/internal/mission" - "github.com/aleksandergregersen/opsquest/internal/profile" "github.com/aleksandergregersen/opsquest/internal/sandbox" ) @@ -302,227 +296,3 @@ func TestRunViEditorIgnoresBracketedPasteInNormalMode(t *testing.T) { t.Fatalf("missing normal-mode paste guidance: %q", output.String()) } } - -func TestViRendererSanitizesFileAndStatusControlBytes(t *testing.T) { - editor, err := newViEditor(sandbox.EditorRequest{ - Path: "/work/file", - DisplayPath: "bad\x1bname", - Content: "safe\x1b[31m\tend CLIPPED_MARKER", - }) - if err != nil { - t.Fatal(err) - } - editor.message = "" - output := &bytes.Buffer{} - if err := renderViEditor(output, editor, 20, 6); err != nil { - t.Fatal(err) - } - rendered := output.String() - if strings.Contains(rendered, "safe\x1b[31m") || strings.Contains(rendered, "bad\x1bname") { - t.Fatalf("rendered virtual controls verbatim: %q", rendered) - } - if !strings.Contains(rendered, "safe^[[31m") || !strings.Contains(rendered, "bad^[name") { - t.Fatalf("rendered content was not visibly sanitized: %q", rendered) - } - if strings.Contains(rendered, "CLIPPED_MARKER") { - t.Fatalf("rendered line was not clipped to the viewport: %q", rendered) - } -} - -func TestViRendererScrollsHorizontallyWithTheCursor(t *testing.T) { - editor := viTestEditor(t, "0123456789abcdefghijklmnopqrstuv") - editor.column = len(editor.lines[0]) - 1 - editor.preferredCol = editor.column - output := &bytes.Buffer{} - if err := renderViEditor(output, editor, 20, 6); err != nil { - t.Fatal(err) - } - rendered := output.String() - if strings.Contains(rendered, "0123456789") || !strings.Contains(rendered, "klmnopqrstuv") { - t.Fatalf("horizontal viewport did not follow the cursor: %q", rendered) - } - if !strings.Contains(rendered, "\x1b[1;20H") { - t.Fatalf("cursor was not placed at the visible right edge: %q", rendered) - } -} - -func TestDisplayViWindowClipsWithoutLosingSpecialCharacterColumns(t *testing.T) { - tests := []struct { - name string - line string - offset int - width int - want string - }{ - {name: "plain", line: "abcdef", offset: 2, width: 3, want: "cde"}, - {name: "inside tab", line: "a\tb", offset: 6, width: 4, want: " b"}, - {name: "control notation", line: "x\x1by", offset: 1, width: 3, want: "^[y"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := displayViWindow([]rune(test.line), test.offset, test.width); got != test.want { - t.Fatalf("displayViWindow() = %q, want %q", got, test.want) - } - }) - } -} - -func BenchmarkRenderViLongLine(b *testing.B) { - editor, err := newViEditor(sandbox.EditorRequest{ - Path: "/work/long.txt", - DisplayPath: "long.txt", - Content: strings.Repeat("x", maxViFileBytes), - }) - if err != nil { - b.Fatal(err) - } - for _, benchmark := range []struct { - name string - column int - }{ - {name: "cursor-start", column: 0}, - {name: "cursor-end", column: len(editor.lines[0]) - 1}, - } { - b.Run(benchmark.name, func(b *testing.B) { - editor.column = benchmark.column - b.ReportAllocs() - for range b.N { - if err := renderViEditor(io.Discard, editor, 80, 24); err != nil { - b.Fatal(err) - } - } - }) - } -} - -func TestNonTerminalViRefusalDoesNotConsumeTheNextCommand(t *testing.T) { - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("linux-config-surgery") - player := profile.New("tester") - out := &bytes.Buffer{} - errOut := &bytes.Buffer{} - session := Session{ - Mission: item, Player: &player, Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), - In: strings.NewReader("vi app.env\nquit\n"), Out: out, ErrOut: errOut, Catalog: catalog, - } - result, err := session.Run() - if err != nil { - t.Fatal(err) - } - if !result.Quit || player.Commands["vi"] != 0 { - t.Fatalf("result = %#v, vi mastery = %d", result, player.Commands["vi"]) - } - if !strings.Contains(errOut.String(), "vi: interactive editor requires a terminal") { - t.Fatalf("stderr = %q", errOut.String()) - } - if !strings.Contains(out.String(), "Mission paused") { - t.Fatalf("following quit command was not consumed: %q", out.String()) - } -} - -type fakeViReader struct { - lines []string - index int - edit func(sandbox.EditorRequest, viSaveFunc) error -} - -func (r *fakeViReader) ReadLine(_ string, _ CompletionSource) (string, error) { - if r.index >= len(r.lines) { - return "", io.EOF - } - line := r.lines[r.index] - r.index++ - return line, nil -} - -func (r *fakeViReader) Edit(request sandbox.EditorRequest, save viSaveFunc) error { - return r.edit(request, save) -} - -func TestViSessionCompletesOutcomeAndRecordsMastery(t *testing.T) { - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("linux-config-surgery") - player := profile.New("tester") - store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester") - reader := &fakeViReader{ - lines: []string{"vi app.env"}, - edit: func(request sandbox.EditorRequest, save viSaveFunc) error { - if request.Path != "/etc/byteworks/app.env" || !strings.Contains(request.Content, "LOG_LEVEL=debug") { - return fmt.Errorf("unexpected editor request: %#v", request) - } - return save(request.Path, strings.Replace(request.Content, "LOG_LEVEL=debug", "LOG_LEVEL=info", 1)) - }, - } - out := &bytes.Buffer{} - session := Session{ - Mission: item, Player: &player, Saver: store, - Out: out, ErrOut: &bytes.Buffer{}, Reader: reader, Catalog: catalog, - Now: func() time.Time { return time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) }, - } - result, err := session.Run() - if err != nil { - t.Fatal(err) - } - if !result.Completed || player.Commands["vi"] != 1 || !player.IsComplete(item.ID) { - t.Fatalf("result = %#v, vi mastery = %d, completed = %v", result, player.Commands["vi"], player.IsComplete(item.ID)) - } - if !strings.Contains(out.String(), "Mission complete") || !strings.Contains(out.String(), "New command discovered: vi") { - t.Fatalf("session output = %q", out.String()) - } -} - -func TestModalFirstAidAcceptsViAndRejectsDiscardedChanges(t *testing.T) { - catalog, err := mission.LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, found := catalog.Find("linux-vi-first-aid") - if !found { - t.Fatal("Modal First Aid mission missing") - } - - for _, test := range []struct { - name string - keys string - complete bool - }{ - {name: "dd and write-quit", keys: "dd:wq\r", complete: true}, - {name: "dd and discard", keys: "dd:q!\r", complete: false}, - } { - t.Run(test.name, func(t *testing.T) { - box, err := sandbox.New(item.Setup, item.StartDir) - if err != nil { - t.Fatal(err) - } - result, err := box.Execute("vi release.env") - if err != nil { - t.Fatal(err) - } - if result.Editor == nil { - t.Fatal("vi did not return an editor request") - } - if err := runViEditor( - newTerminalKeyReader(strings.NewReader(test.keys)), - io.Discard, - *result.Editor, - box.SaveEditorFile, - func() (int, int) { return 80, 12 }, - ); err != nil { - t.Fatal(err) - } - complete, err := Validate(item.Validation, box, "") - if err != nil { - t.Fatal(err) - } - if complete != test.complete { - t.Fatalf("mission complete = %v, want %v", complete, test.complete) - } - }) - } -} diff --git a/internal/mission/catalog.go b/internal/mission/catalog.go index 89b2dbb..a720891 100644 --- a/internal/mission/catalog.go +++ b/internal/mission/catalog.go @@ -7,10 +7,6 @@ import ( "fmt" "io" "io/fs" - "maps" - "path" - "regexp" - "slices" "sort" "strconv" "strings" @@ -95,616 +91,6 @@ func decodeMission(data []byte) (Mission, error) { return item, nil } -var ( - missionIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) - commandNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - variablePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - dockerLogicalNamePattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) - dockerImageReferencePattern = regexp.MustCompile(`^[a-z0-9]+(?:[._/-][a-z0-9]+)*(?::[A-Za-z0-9_.-]+)?@sha256:[a-f0-9]{64}$`) -) - -const ( - maxDockerImagesPerMission = 16 - maxDockerContainersPerMission = 32 - maxDockerFixtureLogBytes = 8 * 1024 - maxDockerNetworksPerMission = 8 - maxDockerNetworksPerContainer = 4 -) - -// reservedDockerNetworkNames are Docker's built-in network names. Lab -// networks cannot use them, so a logical name never suggests host or default -// bridge networking. -var reservedDockerNetworkNames = map[string]bool{"bridge": true, "default": true, "host": true, "none": true} - -// ValidDockerNetworkName reports whether value is a safe logical network name -// that does not shadow one of Docker's built-in networks. -func ValidDockerNetworkName(value string) bool { - return ValidDockerLogicalName(value) && !reservedDockerNetworkNames[value] -} - -// ReservedDockerNetworkName reports whether value names a built-in Docker -// network that labs never expose. -func ReservedDockerNetworkName(value string) bool { - return reservedDockerNetworkNames[value] -} - -// ValidDockerLogicalName reports whether value is safe to use as a stable -// mission alias. Runtime adapters use this same rule so catalog-valid content -// cannot become unplayable at environment setup time. -func ValidDockerLogicalName(value string) bool { - return dockerLogicalNamePattern.MatchString(value) -} - -// ValidDockerImageReference accepts only explicit repository references -// pinned to a sha256 digest. -func ValidDockerImageReference(value string) bool { - return dockerImageReferencePattern.MatchString(value) -} - -func validateMission(item Mission) error { - if !missionIDPattern.MatchString(item.ID) { - return fmt.Errorf("id %q must use lowercase words separated by hyphens", item.ID) - } - track := item.EffectiveTrack() - switch track { - case TrackLinux, TrackDocker: - default: - return fmt.Errorf("unknown track %q", track) - } - environment := item.EffectiveEnvironment() - switch environment { - case EnvironmentSimulated, EnvironmentDocker: - default: - return fmt.Errorf("unknown environment %q", environment) - } - if (track == TrackDocker) != (environment == EnvironmentDocker) { - return fmt.Errorf("track %q cannot use environment %q", track, environment) - } - for name, value := range map[string]string{ - "title": item.Title, "campaign": item.Campaign, "difficulty": item.Difficulty, - "story": item.Story, "objective": item.Objective, "explanation": item.Explanation, - } { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s is required", name) - } - } - if item.Number < 1 { - return fmt.Errorf("number must be positive") - } - switch item.Difficulty { - case DifficultyBeginner, DifficultyIntermediate, DifficultyAdvanced: - default: - return fmt.Errorf("unknown difficulty %q", item.Difficulty) - } - if item.Rewards.XP <= 0 || item.Rewards.HintPenalty < 0 { - return fmt.Errorf("XP must be positive and hint penalty cannot be negative") - } - if len(item.SuggestedCommands) == 0 { - return fmt.Errorf("at least one suggested command is required") - } - seenCommands := make(map[string]bool, len(item.SuggestedCommands)) - for index, command := range item.SuggestedCommands { - if !commandNamePattern.MatchString(command) { - return fmt.Errorf("suggested command %d %q must be a lowercase command name", index+1, command) - } - if seenCommands[command] { - return fmt.Errorf("duplicate suggested command %q", command) - } - seenCommands[command] = true - } - if len(item.Hints) < 1 || len(item.Hints) > 5 { - return fmt.Errorf("missions require between 1 and 5 hints") - } - for index, hint := range item.Hints { - if strings.TrimSpace(hint) == "" { - return fmt.Errorf("hint %d is empty", index+1) - } - } - if environment == EnvironmentSimulated { - if item.Docker != nil { - return fmt.Errorf("simulated mission cannot define docker setup") - } - if err := validateAbsolutePath("start_dir", item.StartDir, true); err != nil { - return err - } - if err := validateSetup(item.Setup, item.StartDir); err != nil { - return err - } - } else { - if item.StartDir != "" { - return fmt.Errorf("docker mission cannot define start_dir") - } - if !setupEmpty(item.Setup) { - return fmt.Errorf("docker mission cannot define simulated setup") - } - if item.Docker == nil { - return fmt.Errorf("docker mission requires docker setup") - } - if err := ValidateDockerSetup(*item.Docker); err != nil { - return err - } - } - if len(item.Validation.All) == 0 { - return fmt.Errorf("at least one validation condition is required") - } - for index, condition := range item.Validation.All { - if err := validateCondition(condition, environment); err != nil { - return fmt.Errorf("validation condition %d: %w", index+1, err) - } - if condition.Container != "" && !dockerSetupHasContainer(item.Docker, condition.Container) { - return fmt.Errorf("validation condition %d: unknown docker container %q", index+1, condition.Container) - } - for _, container := range condition.Containers { - if !dockerSetupHasContainer(item.Docker, container) { - return fmt.Errorf("validation condition %d: unknown docker container %q", index+1, container) - } - } - if condition.Network != "" && !dockerSetupHasNetwork(item.Docker, condition.Network) { - return fmt.Errorf("validation condition %d: unknown docker network %q", index+1, condition.Network) - } - } - return nil -} - -func setupEmpty(setup Setup) bool { - return len(setup.Directories) == 0 && len(setup.Files) == 0 && len(setup.Processes) == 0 && - len(setup.Environment) == 0 && len(setup.Archives) == 0 -} - -// ValidateDockerSetup validates the declarative Docker fixture contract shared -// by catalog loading and the runtime adapter. It does not contact Docker. -func ValidateDockerSetup(setup DockerSetup) error { - if len(setup.Images) == 0 { - return fmt.Errorf("docker setup requires at least one image") - } - if len(setup.Images) > maxDockerImagesPerMission { - return fmt.Errorf("docker setup exceeds the %d-image limit", maxDockerImagesPerMission) - } - if len(setup.Containers) == 0 { - return fmt.Errorf("docker setup requires at least one container") - } - if len(setup.Containers) > maxDockerContainersPerMission { - return fmt.Errorf("docker setup exceeds the %d-container limit", maxDockerContainersPerMission) - } - if len(setup.Networks) > maxDockerNetworksPerMission { - return fmt.Errorf("docker setup exceeds the %d-network limit", maxDockerNetworksPerMission) - } - networks := make(map[string]bool, len(setup.Networks)) - for _, network := range setup.Networks { - if !ValidDockerNetworkName(network.Name) { - return fmt.Errorf("docker network name %q must be a lowercase logical name other than bridge, default, host, or none", network.Name) - } - if networks[network.Name] { - return fmt.Errorf("duplicate docker network name %q", network.Name) - } - networks[network.Name] = true - } - images := make(map[string]bool, len(setup.Images)) - for _, image := range setup.Images { - if !ValidDockerLogicalName(image.Alias) { - return fmt.Errorf("docker image alias %q must be a lowercase logical name", image.Alias) - } - if images[image.Alias] { - return fmt.Errorf("duplicate docker image alias %q", image.Alias) - } - if !ValidDockerImageReference(image.Reference) { - return fmt.Errorf("docker image %q reference must be pinned by sha256 digest", image.Alias) - } - images[image.Alias] = true - } - containers := make(map[string]bool, len(setup.Containers)) - for _, container := range setup.Containers { - if !ValidDockerLogicalName(container.Name) { - return fmt.Errorf("docker container name %q must be a lowercase logical name", container.Name) - } - if containers[container.Name] { - return fmt.Errorf("duplicate docker container name %q", container.Name) - } - if !images[container.Image] { - return fmt.Errorf("docker container %q references unknown image alias %q", container.Name, container.Image) - } - switch container.State { - case DockerStateRunning, DockerStateStopped: - default: - return fmt.Errorf("docker container %q has unknown state %q", container.Name, container.State) - } - if len(container.Log) > maxDockerFixtureLogBytes { - return fmt.Errorf("docker container %q log exceeds the %d-byte limit", container.Name, maxDockerFixtureLogBytes) - } - if strings.ContainsRune(container.Log, 0) { - return fmt.Errorf("docker container %q log cannot contain NUL", container.Name) - } - if err := validateDockerFixtureBehavior(container); err != nil { - return err - } - if len(container.Networks) > maxDockerNetworksPerContainer { - return fmt.Errorf("docker container %q exceeds the %d-network limit", container.Name, maxDockerNetworksPerContainer) - } - joined := make(map[string]bool, len(container.Networks)) - for _, network := range container.Networks { - if !networks[network] { - return fmt.Errorf("docker container %q references unknown network %q", container.Name, network) - } - if joined[network] { - return fmt.Errorf("docker container %q joins network %q twice", container.Name, network) - } - joined[network] = true - } - containers[container.Name] = true - } - return nil -} - -// validateDockerFixtureBehavior accepts only the fixed fixture behaviors the -// Docker adapter implements: a long-lived service with optional startup log -// and health probe, a one-shot diagnostic job, or a bounded crash loop. -func validateDockerFixtureBehavior(container DockerContainerSpec) error { - switch container.Health { - case "", DockerHealthHealthy, DockerHealthUnhealthy: - default: - return fmt.Errorf("docker container %q has unknown health %q", container.Name, container.Health) - } - switch container.Restart { - case "", DockerRestartOnFailure: - default: - return fmt.Errorf("docker container %q has unknown restart behavior %q", container.Name, container.Restart) - } - if container.ExitCode == nil { - if container.Restart != "" { - return fmt.Errorf("docker container %q restart behavior requires log and a non-zero exit_code", container.Name) - } - return nil - } - if container.Log == "" { - return fmt.Errorf("docker container %q exit_code requires a log", container.Name) - } - if *container.ExitCode < 0 || *container.ExitCode > 255 { - return fmt.Errorf("docker container %q exit_code must be between 0 and 255", container.Name) - } - if container.Health != "" { - return fmt.Errorf("docker container %q exiting fixture cannot declare health", container.Name) - } - if container.Restart == DockerRestartOnFailure { - if *container.ExitCode == 0 { - return fmt.Errorf("docker container %q restart behavior requires log and a non-zero exit_code", container.Name) - } - if container.State != DockerStateRunning { - return fmt.Errorf("docker container %q crash-loop fixture must use running state", container.Name) - } - return nil - } - if container.State != DockerStateStopped { - return fmt.Errorf("docker container %q diagnostic fixture must use stopped state", container.Name) - } - return nil -} - -func dockerSetupHasNetwork(setup *DockerSetup, name string) bool { - if setup == nil { - return false - } - for _, network := range setup.Networks { - if network.Name == name { - return true - } - } - return false -} - -func dockerSetupHasContainer(setup *DockerSetup, name string) bool { - if setup == nil { - return false - } - for _, container := range setup.Containers { - if container.Name == name { - return true - } - } - return false -} - -func validateSetup(setup Setup, startDir string) error { - entries := map[string]string{"/": "directory"} - addEntry := func(name, kind string) error { - if existing, found := entries[name]; found { - return fmt.Errorf("setup path %s is both %s and %s", name, existing, kind) - } - entries[name] = kind - return nil - } - for _, directory := range setup.Directories { - if err := validateAbsolutePath("directory", directory.Path, true); err != nil { - return err - } - if err := validateMode(directory.Mode); err != nil { - return fmt.Errorf("directory %s: %w", directory.Path, err) - } - if err := addEntry(directory.Path, "directory"); err != nil { - return err - } - } - for _, file := range setup.Files { - if err := validateAbsolutePath("file", file.Path, false); err != nil { - return err - } - if err := validateMode(file.Mode); err != nil { - return fmt.Errorf("file %s: %w", file.Path, err) - } - if err := addEntry(file.Path, "file"); err != nil { - return err - } - } - for _, archive := range setup.Archives { - if err := validateAbsolutePath("archive", archive.Path, false); err != nil { - return err - } - if err := addEntry(archive.Path, "archive"); err != nil { - return err - } - archiveEntries := make(map[string]bool) - for _, entry := range archive.Entries { - cleaned, err := validateArchiveEntry(entry.Path) - if err != nil { - return fmt.Errorf("archive %s entry %q: %w", archive.Path, entry.Path, err) - } - if archiveEntries[cleaned] { - return fmt.Errorf("archive %s contains duplicate entry %s", archive.Path, cleaned) - } - archiveEntries[cleaned] = true - if err := validateMode(entry.Mode); err != nil { - return fmt.Errorf("archive %s entry %s: %w", archive.Path, entry.Path, err) - } - } - } - for entryPath, kind := range entries { - if entryPath == "/" { - continue - } - for parent := path.Dir(entryPath); parent != "/"; parent = path.Dir(parent) { - if parentKind, exists := entries[parent]; exists && parentKind != "directory" { - return fmt.Errorf("%s %s is nested beneath non-directory %s", kind, entryPath, parent) - } - } - } - for name := range setup.Environment { - if !variablePattern.MatchString(name) { - return fmt.Errorf("invalid environment variable name %q", name) - } - } - pids := make(map[int]bool) - for _, process := range setup.Processes { - if process.PID <= 0 || strings.TrimSpace(process.Command) == "" { - return fmt.Errorf("process PID and command are required") - } - if pids[process.PID] { - return fmt.Errorf("duplicate process PID %d", process.PID) - } - pids[process.PID] = true - } - startExists := startDir == "/" - if kind, exists := entries[startDir]; exists { - if kind != "directory" { - return fmt.Errorf("start_dir %s is not a directory", startDir) - } - startExists = true - } - for entryPath := range entries { - if strings.HasPrefix(entryPath, startDir+"/") { - startExists = true - break - } - } - if !startExists { - return fmt.Errorf("start_dir %s is not created by setup", startDir) - } - return nil -} - -type conditionFields uint8 - -const ( - conditionPath conditionFields = 1 << iota - conditionValue - conditionValues - conditionPID - conditionContainer - conditionCount - conditionNetwork - conditionContainers -) - -var allowedConditionFields = map[ConditionType]conditionFields{ - ConditionOutputEquals: conditionValue, - ConditionOutputContains: conditionValue, - ConditionOutputContainsAll: conditionValues, - ConditionOutputNotContains: conditionValue, - ConditionCWDEquals: conditionValue, - ConditionFileExists: conditionPath, - ConditionDirectoryExists: conditionPath, - ConditionPathMissing: conditionPath, - ConditionFileContentEquals: conditionPath | conditionValue, - ConditionFileContentContains: conditionPath | conditionValue, - ConditionFileLinesEqual: conditionPath | conditionValues, - ConditionFileModeEquals: conditionPath | conditionValue, - ConditionFileOwnerEquals: conditionPath | conditionValue, - ConditionProcessStopped: conditionPID, - ConditionProcessRunning: conditionPID, - ConditionEnvironmentEquals: conditionValue, - ConditionDockerContainerRunning: conditionContainer, - ConditionDockerContainerStopped: conditionContainer, - ConditionDockerContainerCountEqual: conditionCount, - ConditionDockerContainerAbsent: conditionContainer, - ConditionDockerNetworkShared: conditionContainers, - ConditionDockerNetworkIsolated: conditionContainers, - ConditionDockerNetworkAbsent: conditionNetwork, -} - -var conditionFieldFlags = map[string]conditionFields{ - "path": conditionPath, - "value": conditionValue, - "values": conditionValues, - "pid": conditionPID, - "container": conditionContainer, - "count": conditionCount, - "network": conditionNetwork, - "containers": conditionContainers, -} - -func validateCondition(condition Condition, environment string) error { - allowed, known := allowedConditionFields[condition.Type] - if !known { - return fmt.Errorf("unknown type %q", condition.Type) - } - present := condition.present - for _, field := range []struct { - name string - present bool - flag conditionFields - }{ - {name: "path", present: condition.Path != "", flag: conditionPath}, - {name: "value", present: condition.Value != "", flag: conditionValue}, - {name: "values", present: len(condition.Values) != 0, flag: conditionValues}, - {name: "pid", present: condition.PID != 0, flag: conditionPID}, - {name: "container", present: condition.Container != "", flag: conditionContainer}, - {name: "count", present: condition.Count != nil, flag: conditionCount}, - {name: "network", present: condition.Network != "", flag: conditionNetwork}, - {name: "containers", present: len(condition.Containers) != 0, flag: conditionContainers}, - } { - if field.present { - present |= field.flag - } - if present&field.flag != 0 && allowed&field.flag == 0 { - return fmt.Errorf("type %q does not support %s", condition.Type, field.name) - } - } - if allowed&conditionPath != 0 { - if environment != EnvironmentSimulated { - return fmt.Errorf("type %q requires a simulated environment", condition.Type) - } - if err := validateAbsolutePath(string(condition.Type), condition.Path, condition.Type == ConditionDirectoryExists); err != nil { - return err - } - } - switch condition.Type { - case ConditionOutputEquals, ConditionFileExists, ConditionDirectoryExists, ConditionPathMissing, ConditionFileContentEquals: - return nil - case ConditionOutputContains, ConditionOutputNotContains, ConditionFileContentContains: - if condition.Value == "" { - return fmt.Errorf("value cannot be empty") - } - case ConditionOutputContainsAll: - if len(condition.Values) == 0 { - return fmt.Errorf("values cannot be empty") - } - for _, value := range condition.Values { - if value == "" { - return fmt.Errorf("values cannot contain an empty string") - } - } - case ConditionCWDEquals: - if environment != EnvironmentSimulated { - return fmt.Errorf("cwd_equals requires a simulated environment") - } - return validateAbsolutePath("cwd_equals", condition.Value, true) - case ConditionFileLinesEqual: - if len(condition.Values) == 0 { - return fmt.Errorf("values cannot be empty") - } - for _, value := range condition.Values { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("line values cannot be empty") - } - } - case ConditionFileModeEquals: - return validateMode(condition.Value) - case ConditionFileOwnerEquals: - if strings.TrimSpace(condition.Value) == "" { - return fmt.Errorf("owner cannot be empty") - } - case ConditionProcessStopped, ConditionProcessRunning: - if environment != EnvironmentSimulated { - return fmt.Errorf("%s requires a simulated environment", condition.Type) - } - if condition.PID <= 0 { - return fmt.Errorf("PID must be positive") - } - case ConditionEnvironmentEquals: - if environment != EnvironmentSimulated { - return fmt.Errorf("env_equals requires a simulated environment") - } - name, _, found := strings.Cut(condition.Value, "=") - if !found || !variablePattern.MatchString(name) { - return fmt.Errorf("value must be NAME=value") - } - case ConditionDockerContainerRunning, ConditionDockerContainerStopped, ConditionDockerContainerAbsent: - if environment != EnvironmentDocker { - return fmt.Errorf("%s requires a docker environment", condition.Type) - } - if !ValidDockerLogicalName(condition.Container) { - return fmt.Errorf("container %q must be a lowercase logical name", condition.Container) - } - case ConditionDockerNetworkShared, ConditionDockerNetworkIsolated: - if environment != EnvironmentDocker { - return fmt.Errorf("%s requires a docker environment", condition.Type) - } - if len(condition.Containers) != 2 || condition.Containers[0] == condition.Containers[1] { - return fmt.Errorf("containers must name exactly two different containers") - } - for _, container := range condition.Containers { - if !ValidDockerLogicalName(container) { - return fmt.Errorf("container %q must be a lowercase logical name", container) - } - } - case ConditionDockerNetworkAbsent: - if environment != EnvironmentDocker { - return fmt.Errorf("%s requires a docker environment", condition.Type) - } - if !ValidDockerNetworkName(condition.Network) { - return fmt.Errorf("network %q must be a lowercase logical name other than bridge, default, host, or none", condition.Network) - } - case ConditionDockerContainerCountEqual: - if environment != EnvironmentDocker { - return fmt.Errorf("docker_container_count_equals requires a docker environment") - } - if condition.Count == nil || *condition.Count < 0 { - return fmt.Errorf("count must be a non-negative integer") - } - } - return nil -} - -func validateAbsolutePath(kind, value string, allowRoot bool) error { - if !strings.HasPrefix(value, "/") || path.Clean(value) != value { - return fmt.Errorf("%s path %q must be absolute and clean", kind, value) - } - if value == "/" && !allowRoot { - return fmt.Errorf("%s path cannot be /", kind) - } - return nil -} - -func validateMode(value string) error { - if value == "" { - return nil - } - mode, err := strconv.ParseUint(value, 8, 12) - if err != nil || mode > 0o7777 { - return fmt.Errorf("invalid mode %q", value) - } - return nil -} - -func validateArchiveEntry(value string) (string, error) { - if value == "" || strings.HasPrefix(value, "/") { - return "", fmt.Errorf("path must be relative") - } - cleaned := path.Clean(value) - if cleaned != value || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { - return "", fmt.Errorf("path must be clean and remain inside the archive") - } - return cleaned, nil -} - func (c Catalog) All() []Mission { return cloneMissions(c.missions) } @@ -793,51 +179,3 @@ func (c Catalog) findInTrack(track string, start, step int) (Mission, bool) { } return Mission{}, false } - -func cloneMission(item Mission) Mission { - cloned := item - cloned.SuggestedCommands = slices.Clone(item.SuggestedCommands) - cloned.Hints = slices.Clone(item.Hints) - cloned.Setup.Directories = slices.Clone(item.Setup.Directories) - cloned.Setup.Files = slices.Clone(item.Setup.Files) - cloned.Setup.Processes = slices.Clone(item.Setup.Processes) - cloned.Setup.Archives = make([]ArchiveSpec, len(item.Setup.Archives)) - for index, archive := range item.Setup.Archives { - cloned.Setup.Archives[index] = archive - cloned.Setup.Archives[index].Entries = slices.Clone(archive.Entries) - } - cloned.Setup.Environment = maps.Clone(item.Setup.Environment) - if item.Docker != nil { - dockerSetup := *item.Docker - dockerSetup.Images = slices.Clone(item.Docker.Images) - dockerSetup.Networks = slices.Clone(item.Docker.Networks) - dockerSetup.Containers = slices.Clone(item.Docker.Containers) - for index, container := range dockerSetup.Containers { - dockerSetup.Containers[index].Networks = slices.Clone(container.Networks) - if container.ExitCode != nil { - exitCode := *container.ExitCode - dockerSetup.Containers[index].ExitCode = &exitCode - } - } - cloned.Docker = &dockerSetup - } - cloned.Validation.All = make([]Condition, len(item.Validation.All)) - for index, condition := range item.Validation.All { - cloned.Validation.All[index] = condition - cloned.Validation.All[index].Values = slices.Clone(condition.Values) - cloned.Validation.All[index].Containers = slices.Clone(condition.Containers) - if condition.Count != nil { - count := *condition.Count - cloned.Validation.All[index].Count = &count - } - } - return cloned -} - -func cloneMissions(items []Mission) []Mission { - cloned := make([]Mission, len(items)) - for index, item := range items { - cloned[index] = cloneMission(item) - } - return cloned -} diff --git a/internal/mission/catalog_test.go b/internal/mission/catalog_test.go index e105b7e..155ed18 100644 --- a/internal/mission/catalog_test.go +++ b/internal/mission/catalog_test.go @@ -1,8 +1,6 @@ package mission import ( - "encoding/json" - "strings" "testing" ) @@ -72,791 +70,6 @@ func TestLegacyMissionDefaultsAndCatalogTrackFiltering(t *testing.T) { } } -func TestAutomationShiftCurriculum(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - wants := []struct { - id string - number int - difficulty string - hints int - }{ - {id: "linux-runbook-runner", number: 26, difficulty: "beginner", hints: 3}, - {id: "linux-vi-first-aid", number: 27, difficulty: "beginner", hints: 3}, - {id: "linux-report-on-repeat", number: 28, difficulty: "intermediate", hints: 4}, - {id: "linux-scope-creep", number: 29, difficulty: "advanced", hints: 5}, - } - for _, want := range wants { - item, found := catalog.Find(want.id) - if !found { - t.Errorf("mission %q missing", want.id) - continue - } - if item.Number != want.number || item.Campaign != "The Automation Shift" || item.Difficulty != want.difficulty || len(item.Hints) != want.hints { - t.Errorf("mission %q curriculum metadata = number %d, campaign %q, difficulty %q, hints %d", item.ID, item.Number, item.Campaign, item.Difficulty, len(item.Hints)) - } - } -} - -func TestRebalancedLinuxCurriculumMetadata(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - - wants := []struct { - id string - campaign string - difficulty string - hints int - }{ - {id: "linux-find-logs", campaign: "First Day", difficulty: "beginner", hints: 3}, - {id: "linux-release-shuffle", campaign: "First Day", difficulty: "beginner", hints: 3}, - {id: "linux-permissions", campaign: "The Logpocalypse", difficulty: "beginner", hints: 3}, - {id: "linux-pipeline-report", campaign: "The Logpocalypse", difficulty: "advanced", hints: 5}, - {id: "linux-production-friday", campaign: "Production Friday", difficulty: "advanced", hints: 5}, - } - for _, want := range wants { - item, found := catalog.Find(want.id) - if !found { - t.Errorf("mission %q missing", want.id) - continue - } - if item.Campaign != want.campaign || item.Difficulty != want.difficulty || len(item.Hints) != want.hints { - t.Errorf("mission %q curriculum metadata = campaign %q, difficulty %q, hints %d; want %q, %q, %d", - item.ID, item.Campaign, item.Difficulty, len(item.Hints), want.campaign, want.difficulty, want.hints) - } - } - - items := catalog.InTrack(TrackLinux) - for _, item := range items { - switch { - case item.Number >= 1 && item.Number <= 6 && item.Campaign != "First Day": - t.Errorf("World 1 mission %d campaign = %q, want First Day", item.Number, item.Campaign) - case item.Number >= 7 && item.Number <= 12 && item.Campaign != "The Logpocalypse": - t.Errorf("World 2 mission %d campaign = %q, want The Logpocalypse", item.Number, item.Campaign) - } - } -} - -func TestBeginnerLinuxExpansionMetadata(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - wants := []struct { - id string - number int - campaign string - }{ - {id: "linux-read-handoff", number: 3, campaign: "First Day"}, - {id: "linux-log-preview", number: 9, campaign: "The Logpocalypse"}, - {id: "linux-error-headcount", number: 14, campaign: "Production Friday"}, - {id: "linux-runbook-runner", number: 26, campaign: "The Automation Shift"}, - } - for _, want := range wants { - item, found := catalog.Find(want.id) - if !found { - t.Errorf("mission %q missing", want.id) - continue - } - if item.Number != want.number || item.Campaign != want.campaign || item.Difficulty != DifficultyBeginner || len(item.Hints) != 3 { - t.Errorf("mission %q metadata = number %d, campaign %q, difficulty %q, hints %d", item.ID, item.Number, item.Campaign, item.Difficulty, len(item.Hints)) - } - } -} - -func TestContainerCensusUsesTypedDockerSetup(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, found := catalog.Find("docker-container-census") - if !found { - t.Fatal("Container Census mission missing") - } - if item.EffectiveTrack() != TrackDocker || item.EffectiveEnvironment() != EnvironmentDocker || item.Docker == nil { - t.Fatalf("docker mission discriminators/setup = %#v", item) - } - if len(item.Docker.Images) != 1 || item.Docker.Images[0].Alias != "fixture" || !strings.Contains(item.Docker.Images[0].Reference, "@sha256:") { - t.Fatalf("docker images = %#v", item.Docker.Images) - } - if len(item.Docker.Containers) != 2 || item.Docker.Containers[0].Name != "api" || item.Docker.Containers[0].State != "stopped" || item.Docker.Containers[1].State != "running" { - t.Fatalf("docker containers = %#v", item.Docker.Containers) - } - if len(item.Validation.All) != 3 || item.Validation.All[2].Count == nil || *item.Validation.All[2].Count != 2 { - t.Fatalf("docker validation = %#v", item.Validation.All) - } -} - -func TestExpandedDockerCurriculumMetadata(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - wants := []string{ - "docker-container-census", - "docker-last-broadcast", - "docker-exit-code-detective", - "docker-quiet-worker", - "docker-recovery-pair", - "docker-shift-handoff", - } - for index, id := range wants { - item, found := catalog.Find(id) - if !found { - t.Errorf("Docker mission %q missing", id) - continue - } - if item.Number != 20+index || item.Campaign != "It Works on My Machine" || item.Difficulty != DifficultyBeginner { - t.Errorf("Docker mission %q metadata = number %d, campaign %q, difficulty %q", id, item.Number, item.Campaign, item.Difficulty) - } - } - diagnostic, _ := catalog.Find("docker-last-broadcast") - if diagnostic.Docker == nil || len(diagnostic.Docker.Containers) != 1 || diagnostic.Docker.Containers[0].Log == "" || diagnostic.Docker.Containers[0].ExitCode == nil || *diagnostic.Docker.Containers[0].ExitCode != 78 { - t.Fatalf("diagnostic Docker setup = %#v", diagnostic.Docker) - } -} - -func TestContainerTriageCurriculum(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - wants := []struct { - id string - number int - difficulty string - hints int - }{ - {id: "docker-janitor-duty", number: 30, difficulty: DifficultyBeginner, hints: 3}, - {id: "docker-tail-end", number: 31, difficulty: DifficultyBeginner, hints: 3}, - {id: "docker-running-isnt-healthy", number: 32, difficulty: DifficultyIntermediate, hints: 3}, - {id: "docker-crash-loop", number: 33, difficulty: DifficultyIntermediate, hints: 3}, - {id: "docker-postmortem-triage", number: 34, difficulty: DifficultyAdvanced, hints: 5}, - } - for _, want := range wants { - item, found := catalog.Find(want.id) - if !found { - t.Errorf("mission %q missing", want.id) - continue - } - if item.Number != want.number || item.Campaign != "Container Triage" || item.EffectiveTrack() != TrackDocker || item.Difficulty != want.difficulty || len(item.Hints) != want.hints { - t.Errorf("mission %q curriculum metadata = number %d, campaign %q, track %q, difficulty %q, hints %d", item.ID, item.Number, item.Campaign, item.EffectiveTrack(), item.Difficulty, len(item.Hints)) - } - } - worlds := catalog.Worlds(TrackDocker) - if len(worlds) < 2 || worlds[1].Name != "Container Triage" || len(worlds[1].Missions) != 5 { - t.Fatalf("Docker worlds = %d, want Container Triage as World 2", len(worlds)) - } - crashLoop, _ := catalog.Find("docker-crash-loop") - payments := crashLoop.Docker.Containers[0] - if payments.Restart != DockerRestartOnFailure || payments.State != DockerStateRunning || payments.ExitCode == nil || *payments.ExitCode == 0 { - t.Fatalf("crash-loop fixture = %#v", payments) - } -} - -func TestNetworkPlumbingCurriculum(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - wants := []struct { - id string - number int - difficulty string - hints int - }{ - {id: "docker-network-census", number: 35, difficulty: DifficultyBeginner, hints: 3}, - {id: "docker-cant-reach-the-db", number: 36, difficulty: DifficultyBeginner, hints: 3}, - {id: "docker-need-to-know", number: 37, difficulty: DifficultyIntermediate, hints: 3}, - {id: "docker-private-channel", number: 38, difficulty: DifficultyIntermediate, hints: 3}, - {id: "docker-segmentation", number: 39, difficulty: DifficultyAdvanced, hints: 5}, - } - for _, want := range wants { - item, found := catalog.Find(want.id) - if !found { - t.Errorf("mission %q missing", want.id) - continue - } - if item.Number != want.number || item.Campaign != "Network Plumbing" || item.EffectiveTrack() != TrackDocker || item.Difficulty != want.difficulty || len(item.Hints) != want.hints { - t.Errorf("mission %q curriculum metadata = number %d, campaign %q, track %q, difficulty %q, hints %d", item.ID, item.Number, item.Campaign, item.EffectiveTrack(), item.Difficulty, len(item.Hints)) - } - if len(item.Docker.Networks) == 0 { - t.Errorf("network mission %q declares no networks", item.ID) - } - } - worlds := catalog.Worlds(TrackDocker) - if len(worlds) != 3 || worlds[2].Name != "Network Plumbing" || len(worlds[2].Missions) != 5 { - t.Fatalf("Docker worlds = %d, want Network Plumbing as World 3", len(worlds)) - } -} - -func TestMissionValidationRejectsInvalidDockerDefinitions(t *testing.T) { - tests := []struct { - name string - mutate func(*Mission) - wantErr string - }{ - { - name: "missing setup", - mutate: func(item *Mission) { - item.Docker = nil - }, - wantErr: "requires docker setup", - }, - { - name: "mixed simulated setup", - mutate: func(item *Mission) { - item.Setup.Files = []FileSpec{{Path: "/host-shaped", Content: "no"}} - }, - wantErr: "cannot define simulated setup", - }, - { - name: "duplicate image alias", - mutate: func(item *Mission) { - item.Docker.Images = append(item.Docker.Images, item.Docker.Images[0]) - }, - wantErr: "duplicate docker image alias", - }, - { - name: "unpinned image", - mutate: func(item *Mission) { - item.Docker.Images[0].Reference = "docker.io/library/busybox:latest" - }, - wantErr: "pinned by sha256 digest", - }, - { - name: "duplicate container", - mutate: func(item *Mission) { - item.Docker.Containers = append(item.Docker.Containers, item.Docker.Containers[0]) - }, - wantErr: "duplicate docker container name", - }, - { - name: "unknown image alias", - mutate: func(item *Mission) { - item.Docker.Containers[0].Image = "missing" - }, - wantErr: "references unknown image alias", - }, - { - name: "unknown state", - mutate: func(item *Mission) { - item.Docker.Containers[0].State = "paused" - }, - wantErr: "unknown state", - }, - { - name: "diagnostic exit code without log", - mutate: func(item *Mission) { - exitCode := 1 - item.Docker.Containers[0].ExitCode = &exitCode - }, - wantErr: "exit_code requires a log", - }, - { - name: "service log too large", - mutate: func(item *Mission) { - item.Docker.Containers[1].Log = strings.Repeat("x", maxDockerFixtureLogBytes+1) - }, - wantErr: "log exceeds", - }, - { - name: "unknown health", - mutate: func(item *Mission) { - item.Docker.Containers[1].Health = "starting" - }, - wantErr: "unknown health", - }, - { - name: "health on exiting fixture", - mutate: func(item *Mission) { - exitCode := 0 - item.Docker.Containers[0].Log = "complete" - item.Docker.Containers[0].ExitCode = &exitCode - item.Docker.Containers[0].Health = DockerHealthHealthy - }, - wantErr: "cannot declare health", - }, - { - name: "unknown restart behavior", - mutate: func(item *Mission) { - item.Docker.Containers[1].Restart = "always" - }, - wantErr: "unknown restart behavior", - }, - { - name: "restart without crash", - mutate: func(item *Mission) { - item.Docker.Containers[1].Restart = DockerRestartOnFailure - }, - wantErr: "requires log and a non-zero exit_code", - }, - { - name: "restart with successful exit", - mutate: func(item *Mission) { - exitCode := 0 - item.Docker.Containers[1].Log = "complete" - item.Docker.Containers[1].ExitCode = &exitCode - item.Docker.Containers[1].Restart = DockerRestartOnFailure - }, - wantErr: "requires log and a non-zero exit_code", - }, - { - name: "crash loop declared stopped", - mutate: func(item *Mission) { - exitCode := 1 - item.Docker.Containers[0].Log = "FATAL" - item.Docker.Containers[0].ExitCode = &exitCode - item.Docker.Containers[0].Restart = DockerRestartOnFailure - }, - wantErr: "crash-loop fixture must use running state", - }, - { - name: "reserved network name", - mutate: func(item *Mission) { - item.Docker.Networks = []DockerNetworkSpec{{Name: "host"}} - }, - wantErr: "other than bridge, default, host, or none", - }, - { - name: "duplicate network", - mutate: func(item *Mission) { - item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}, {Name: "app-net"}} - }, - wantErr: "duplicate docker network name", - }, - { - name: "too many networks", - mutate: func(item *Mission) { - for index := 0; index <= maxDockerNetworksPerMission; index++ { - item.Docker.Networks = append(item.Docker.Networks, DockerNetworkSpec{Name: "net-" + strings.Repeat("a", index+1)}) - } - }, - wantErr: "network limit", - }, - { - name: "container joins unknown network", - mutate: func(item *Mission) { - item.Docker.Containers[0].Networks = []string{"missing"} - }, - wantErr: "references unknown network", - }, - { - name: "container joins network twice", - mutate: func(item *Mission) { - item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}} - item.Docker.Containers[0].Networks = []string{"app-net", "app-net"} - }, - wantErr: "joins network \"app-net\" twice", - }, - { - name: "container joins too many networks", - mutate: func(item *Mission) { - for _, name := range []string{"a-net", "b-net", "c-net", "d-net", "e-net"} { - item.Docker.Networks = append(item.Docker.Networks, DockerNetworkSpec{Name: name}) - item.Docker.Containers[0].Networks = append(item.Docker.Containers[0].Networks, name) - } - }, - wantErr: "exceeds the 4-network limit", - }, - { - name: "shared network with one container", - mutate: func(item *Mission) { - item.Validation.All[0] = Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api"}} - }, - wantErr: "exactly two different containers", - }, - { - name: "isolated container compared with itself", - mutate: func(item *Mission) { - item.Validation.All[0] = Condition{Type: ConditionDockerNetworkIsolated, Containers: []string{"api", "api"}} - }, - wantErr: "exactly two different containers", - }, - { - name: "shared network with unknown container", - mutate: func(item *Mission) { - item.Validation.All[0] = Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api", "missing"}} - }, - wantErr: "unknown docker container", - }, - { - name: "shared network condition with network field", - mutate: func(item *Mission) { - item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}} - item.Validation.All[0] = Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api", "metrics"}, Network: "app-net"} - }, - wantErr: "does not support network", - }, - { - name: "unknown absent network", - mutate: func(item *Mission) { - item.Validation.All[0] = Condition{Type: ConditionDockerNetworkAbsent, Network: "missing"} - }, - wantErr: "unknown docker network", - }, - { - name: "reserved absent network", - mutate: func(item *Mission) { - item.Validation.All[0] = Condition{Type: ConditionDockerNetworkAbsent, Network: "none"} - }, - wantErr: "other than bridge, default, host, or none", - }, - { - name: "unknown absent validation container", - mutate: func(item *Mission) { - item.Validation.All[0] = Condition{Type: ConditionDockerContainerAbsent, Container: "missing"} - }, - wantErr: "unknown docker container", - }, - { - name: "absent condition with count", - mutate: func(item *Mission) { - count := 1 - item.Validation.All[0] = Condition{Type: ConditionDockerContainerAbsent, Container: "api", Count: &count} - }, - wantErr: "does not support count", - }, - { - name: "diagnostic exit code outside range", - mutate: func(item *Mission) { - exitCode := 256 - item.Docker.Containers[0].Log = "diagnostic" - item.Docker.Containers[0].ExitCode = &exitCode - }, - wantErr: "between 0 and 255", - }, - { - name: "diagnostic negative exit code", - mutate: func(item *Mission) { - exitCode := -1 - item.Docker.Containers[0].Log = "diagnostic" - item.Docker.Containers[0].ExitCode = &exitCode - }, - wantErr: "between 0 and 255", - }, - { - name: "diagnostic log too large", - mutate: func(item *Mission) { - exitCode := 1 - item.Docker.Containers[0].Log = strings.Repeat("x", maxDockerFixtureLogBytes+1) - item.Docker.Containers[0].ExitCode = &exitCode - }, - wantErr: "log exceeds", - }, - { - name: "diagnostic log contains NUL", - mutate: func(item *Mission) { - exitCode := 1 - item.Docker.Containers[0].Log = "bad\x00log" - item.Docker.Containers[0].ExitCode = &exitCode - }, - wantErr: "cannot contain NUL", - }, - { - name: "diagnostic fixture declared running", - mutate: func(item *Mission) { - exitCode := 0 - item.Docker.Containers[1].Log = "complete" - item.Docker.Containers[1].ExitCode = &exitCode - }, - wantErr: "must use stopped state", - }, - { - name: "unknown validation container", - mutate: func(item *Mission) { - item.Validation.All[0].Container = "missing" - }, - wantErr: "unknown docker container", - }, - { - name: "unknown stopped validation container", - mutate: func(item *Mission) { - item.Validation.All[0] = Condition{Type: ConditionDockerContainerStopped, Container: "missing"} - }, - wantErr: "unknown docker container", - }, - { - name: "missing count", - mutate: func(item *Mission) { - item.Validation.All[2].Count = nil - }, - wantErr: "count must be a non-negative integer", - }, - { - name: "negative count", - mutate: func(item *Mission) { - count := -1 - item.Validation.All[2].Count = &count - }, - wantErr: "count must be a non-negative integer", - }, - { - name: "running condition with unrelated value", - mutate: func(item *Mission) { - item.Validation.All[0].Value = "api" - }, - wantErr: "does not support value", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("docker-container-census") - test.mutate(&item) - err = validateMission(item) - if err == nil || !strings.Contains(err.Error(), test.wantErr) { - t.Fatalf("validateMission() error = %v, want substring %q", err, test.wantErr) - } - }) - } -} - -func TestMissionValidationAcceptsDockerFixtureBehaviors(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("docker-container-census") - failure := 3 - item.Docker.Containers = []DockerContainerSpec{ - {Name: "api", Image: "fixture", State: DockerStateStopped, Log: "INFO ready", Health: DockerHealthHealthy}, - {Name: "metrics", Image: "fixture", State: DockerStateRunning, Health: DockerHealthUnhealthy}, - {Name: "payments", Image: "fixture", State: DockerStateRunning, Log: "FATAL key missing", ExitCode: &failure, Restart: DockerRestartOnFailure}, - } - item.Validation.All = append(item.Validation.All, Condition{Type: ConditionDockerContainerAbsent, Container: "payments"}) - if err := validateMission(item); err != nil { - t.Fatalf("validateMission() error = %v", err) - } -} - -func TestMissionValidationAcceptsDockerNetworks(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("docker-container-census") - item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}, {Name: "db-net"}} - item.Docker.Containers[0].Networks = []string{"app-net", "db-net"} - item.Docker.Containers[1].Networks = []string{"app-net"} - item.Validation.All = append(item.Validation.All, - Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api", "metrics"}}, - Condition{Type: ConditionDockerNetworkIsolated, Containers: []string{"metrics", "api"}}, - Condition{Type: ConditionDockerNetworkAbsent, Network: "db-net"}, - ) - if err := validateMission(item); err != nil { - t.Fatalf("validateMission() error = %v", err) - } - for _, name := range []string{"bridge", "default", "host", "none"} { - if ValidDockerNetworkName(name) || !ReservedDockerNetworkName(name) { - t.Errorf("network name %q was not treated as reserved", name) - } - } -} - -func TestDockerNetworkFieldsAreDeepCopied(t *testing.T) { - item := Mission{ - Docker: &DockerSetup{ - Networks: []DockerNetworkSpec{{Name: "app-net"}}, - Containers: []DockerContainerSpec{{Name: "api", Networks: []string{"app-net"}}}, - }, - Validation: Validation{All: []Condition{{Type: ConditionDockerNetworkShared, Containers: []string{"api", "db"}}}}, - } - cloned := cloneMission(item) - cloned.Docker.Networks[0].Name = "changed" - cloned.Docker.Containers[0].Networks[0] = "changed" - cloned.Validation.All[0].Containers[0] = "changed" - if item.Docker.Networks[0].Name != "app-net" || item.Docker.Containers[0].Networks[0] != "app-net" || item.Validation.All[0].Containers[0] != "api" { - t.Fatalf("cloneMission shared network data: %#v", item) - } -} - -func TestMissionValidationRejectsDockerConditionOnSimulatedMission(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("linux-orientation") - for _, condition := range []Condition{ - {Type: ConditionDockerContainerRunning, Container: "api"}, - {Type: ConditionDockerContainerAbsent, Container: "api"}, - {Type: ConditionDockerNetworkShared, Containers: []string{"api", "db"}}, - {Type: ConditionDockerNetworkIsolated, Containers: []string{"api", "db"}}, - {Type: ConditionDockerNetworkAbsent, Network: "app-net"}, - } { - item.Validation.All = []Condition{condition} - if err := validateMission(item); err == nil || !strings.Contains(err.Error(), "requires a docker environment") { - t.Fatalf("validateMission(%s) error = %v", condition.Type, err) - } - } -} - -func TestDockerSetupResourceLimits(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("docker-container-census") - - tooManyImages := *item.Docker - tooManyImages.Images = make([]DockerImageSpec, maxDockerImagesPerMission+1) - if err := ValidateDockerSetup(tooManyImages); err == nil || !strings.Contains(err.Error(), "image limit") { - t.Fatalf("ValidateDockerSetup(images) error = %v", err) - } - - tooManyContainers := *item.Docker - tooManyContainers.Containers = make([]DockerContainerSpec, maxDockerContainersPerMission+1) - if err := ValidateDockerSetup(tooManyContainers); err == nil || !strings.Contains(err.Error(), "container limit") { - t.Fatalf("ValidateDockerSetup(containers) error = %v", err) - } -} - -func TestMissionValidationRejectsUnknownTrackAndEnvironment(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - base, _ := catalog.Find("linux-orientation") - tests := []struct { - name string - mutate func(*Mission) - wantErr string - }{ - { - name: "track", - mutate: func(item *Mission) { item.Track = "cloud" }, - wantErr: `unknown track "cloud"`, - }, - { - name: "environment", - mutate: func(item *Mission) { item.Environment = "host" }, - wantErr: `unknown environment "host"`, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - item := base - test.mutate(&item) - if err := validateMission(item); err == nil || !strings.Contains(err.Error(), test.wantErr) { - t.Fatalf("validateMission() error = %v, want substring %q", err, test.wantErr) - } - }) - } -} - -func TestMissionValidationRejectsUnknownDifficultyAndInvalidHintCount(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - base, _ := catalog.Find("linux-orientation") - tests := []struct { - name string - mutate func(*Mission) - wantErr string - }{ - { - name: "unknown difficulty", - mutate: func(item *Mission) { item.Difficulty = "expert-ish" }, - wantErr: "unknown difficulty", - }, - { - name: "no hints", - mutate: func(item *Mission) { item.Hints = nil }, - wantErr: "between 1 and 5 hints", - }, - { - name: "too many hints", - mutate: func(item *Mission) { item.Hints = make([]string, 6) }, - wantErr: "between 1 and 5 hints", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - item := base - test.mutate(&item) - if err := validateMission(item); err == nil || !strings.Contains(err.Error(), test.wantErr) { - t.Fatalf("validateMission() error = %v, want substring %q", err, test.wantErr) - } - }) - } -} - -func TestMissionValidationRejectsInvalidSuggestedCommands(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - base, _ := catalog.Find("linux-orientation") - tests := []struct { - name string - commands []string - wantErr string - }{ - {name: "missing", commands: nil, wantErr: "at least one suggested command"}, - {name: "blank", commands: []string{""}, wantErr: "lowercase command name"}, - {name: "syntax instead of command name", commands: []string{"grep ERROR"}, wantErr: "lowercase command name"}, - {name: "duplicate", commands: []string{"pwd", "pwd"}, wantErr: "duplicate suggested command"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - item := base - item.SuggestedCommands = test.commands - if err := validateMission(item); err == nil || !strings.Contains(err.Error(), test.wantErr) { - t.Fatalf("validateMission() error = %v, want substring %q", err, test.wantErr) - } - }) - } -} - -func TestMissionValidationRejectsFieldsOutsideConditionContract(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("linux-orientation") - item.Validation.All[0].Path = "/not-used" - if err := validateMission(item); err == nil || !strings.Contains(err.Error(), `type "output_equals" does not support path`) { - t.Fatalf("validateMission() error = %v", err) - } -} - -func TestConditionValidationRejectsExplicitZeroValueFieldsOutsideContract(t *testing.T) { - tests := []struct { - data string - wantErr string - }{ - {data: `{"type":"output_equals","pid":0}`, wantErr: `does not support pid`}, - {data: `{"type":"file_exists","value":""}`, wantErr: `does not support value`}, - {data: `{"type":"path_missing","values":[]}`, wantErr: `does not support values`}, - } - for _, test := range tests { - var condition Condition - if err := json.Unmarshal([]byte(test.data), &condition); err != nil { - t.Fatalf("Unmarshal(%s) error = %v", test.data, err) - } - if err := validateCondition(condition, EnvironmentSimulated); err == nil || !strings.Contains(err.Error(), test.wantErr) { - t.Errorf("validateCondition(%s) error = %v, want substring %q", test.data, err, test.wantErr) - } - } -} - -func TestConditionDecoderPreservesUnknownFieldRejection(t *testing.T) { - var condition Condition - err := json.Unmarshal([]byte(`{"type":"output_equals","surprise":0}`), &condition) - if err == nil || !strings.Contains(err.Error(), `unknown field "surprise"`) { - t.Fatalf("Unmarshal() error = %v", err) - } -} - func TestCatalogResultsCannotMutateCatalogState(t *testing.T) { catalog, err := LoadCatalog() if err != nil { @@ -959,37 +172,3 @@ func TestNextReturnsFirstIncompleteMission(t *testing.T) { t.Fatalf("Next() = %#v, %v; want mission 3", item, found) } } - -func TestMissionDecoderRejectsUnknownFields(t *testing.T) { - _, err := decodeMission([]byte(`{"id":"test","surprise":true}`)) - if err == nil { - t.Fatal("unknown mission field was accepted") - } -} - -func TestMissionValidationRejectsUnsafeArchiveEntries(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, found := catalog.Find("linux-archive-rescue") - if !found { - t.Fatal("archive mission missing") - } - item.Setup.Archives[0].Entries[0].Path = "../../outside" - if err := validateMission(item); err == nil { - t.Fatal("unsafe archive entry was accepted") - } -} - -func TestMissionValidationRejectsConflictingSetupPaths(t *testing.T) { - catalog, err := LoadCatalog() - if err != nil { - t.Fatal(err) - } - item, _ := catalog.Find("1") - item.Setup.Files = append(item.Setup.Files, FileSpec{Path: "/home/operator", Content: "conflict"}) - if err := validateMission(item); err == nil { - t.Fatal("directory/file path conflict was accepted") - } -} diff --git a/internal/mission/clone.go b/internal/mission/clone.go new file mode 100644 index 0000000..52edf44 --- /dev/null +++ b/internal/mission/clone.go @@ -0,0 +1,54 @@ +package mission + +import ( + "maps" + "slices" +) + +func cloneMission(item Mission) Mission { + cloned := item + cloned.SuggestedCommands = slices.Clone(item.SuggestedCommands) + cloned.Hints = slices.Clone(item.Hints) + cloned.Setup.Directories = slices.Clone(item.Setup.Directories) + cloned.Setup.Files = slices.Clone(item.Setup.Files) + cloned.Setup.Processes = slices.Clone(item.Setup.Processes) + cloned.Setup.Archives = make([]ArchiveSpec, len(item.Setup.Archives)) + for index, archive := range item.Setup.Archives { + cloned.Setup.Archives[index] = archive + cloned.Setup.Archives[index].Entries = slices.Clone(archive.Entries) + } + cloned.Setup.Environment = maps.Clone(item.Setup.Environment) + if item.Docker != nil { + dockerSetup := *item.Docker + dockerSetup.Images = slices.Clone(item.Docker.Images) + dockerSetup.Networks = slices.Clone(item.Docker.Networks) + dockerSetup.Containers = slices.Clone(item.Docker.Containers) + for index, container := range dockerSetup.Containers { + dockerSetup.Containers[index].Networks = slices.Clone(container.Networks) + if container.ExitCode != nil { + exitCode := *container.ExitCode + dockerSetup.Containers[index].ExitCode = &exitCode + } + } + cloned.Docker = &dockerSetup + } + cloned.Validation.All = make([]Condition, len(item.Validation.All)) + for index, condition := range item.Validation.All { + cloned.Validation.All[index] = condition + cloned.Validation.All[index].Values = slices.Clone(condition.Values) + cloned.Validation.All[index].Containers = slices.Clone(condition.Containers) + if condition.Count != nil { + count := *condition.Count + cloned.Validation.All[index].Count = &count + } + } + return cloned +} + +func cloneMissions(items []Mission) []Mission { + cloned := make([]Mission, len(items)) + for index, item := range items { + cloned[index] = cloneMission(item) + } + return cloned +} diff --git a/internal/mission/conditions.go b/internal/mission/conditions.go new file mode 100644 index 0000000..aaed0f5 --- /dev/null +++ b/internal/mission/conditions.go @@ -0,0 +1,224 @@ +package mission + +import ( + "encoding/json" + "fmt" + "strings" +) + +type conditionFields uint8 + +const ( + conditionPath conditionFields = 1 << iota + conditionValue + conditionValues + conditionPID + conditionContainer + conditionCount + conditionNetwork + conditionContainers +) + +var allowedConditionFields = map[ConditionType]conditionFields{ + ConditionOutputEquals: conditionValue, + ConditionOutputContains: conditionValue, + ConditionOutputContainsAll: conditionValues, + ConditionOutputNotContains: conditionValue, + ConditionCWDEquals: conditionValue, + ConditionFileExists: conditionPath, + ConditionDirectoryExists: conditionPath, + ConditionPathMissing: conditionPath, + ConditionFileContentEquals: conditionPath | conditionValue, + ConditionFileContentContains: conditionPath | conditionValue, + ConditionFileLinesEqual: conditionPath | conditionValues, + ConditionFileModeEquals: conditionPath | conditionValue, + ConditionFileOwnerEquals: conditionPath | conditionValue, + ConditionProcessStopped: conditionPID, + ConditionProcessRunning: conditionPID, + ConditionEnvironmentEquals: conditionValue, + ConditionDockerContainerRunning: conditionContainer, + ConditionDockerContainerStopped: conditionContainer, + ConditionDockerContainerCountEqual: conditionCount, + ConditionDockerContainerAbsent: conditionContainer, + ConditionDockerNetworkShared: conditionContainers, + ConditionDockerNetworkIsolated: conditionContainers, + ConditionDockerNetworkAbsent: conditionNetwork, +} + +var conditionFieldFlags = map[string]conditionFields{ + "path": conditionPath, + "value": conditionValue, + "values": conditionValues, + "pid": conditionPID, + "container": conditionContainer, + "count": conditionCount, + "network": conditionNetwork, + "containers": conditionContainers, +} + +func validateCondition(condition Condition, environment string) error { + allowed, known := allowedConditionFields[condition.Type] + if !known { + return fmt.Errorf("unknown type %q", condition.Type) + } + if err := checkConditionFields(condition, allowed); err != nil { + return err + } + if allowed&conditionPath != 0 { + if environment != EnvironmentSimulated { + return fmt.Errorf("type %q requires a simulated environment", condition.Type) + } + if err := validateAbsolutePath(string(condition.Type), condition.Path, condition.Type == ConditionDirectoryExists); err != nil { + return err + } + } + return validateConditionValues(condition, environment) +} + +// checkConditionFields rejects any field, even an explicit zero value in +// JSON, that the condition type does not support. +func checkConditionFields(condition Condition, allowed conditionFields) error { + present := condition.present + for _, field := range []struct { + name string + present bool + flag conditionFields + }{ + {name: "path", present: condition.Path != "", flag: conditionPath}, + {name: "value", present: condition.Value != "", flag: conditionValue}, + {name: "values", present: len(condition.Values) != 0, flag: conditionValues}, + {name: "pid", present: condition.PID != 0, flag: conditionPID}, + {name: "container", present: condition.Container != "", flag: conditionContainer}, + {name: "count", present: condition.Count != nil, flag: conditionCount}, + {name: "network", present: condition.Network != "", flag: conditionNetwork}, + {name: "containers", present: len(condition.Containers) != 0, flag: conditionContainers}, + } { + if field.present { + present |= field.flag + } + if present&field.flag != 0 && allowed&field.flag == 0 { + return fmt.Errorf("type %q does not support %s", condition.Type, field.name) + } + } + return nil +} + +// validateConditionValues applies each condition type's own value and +// environment rules. +func validateConditionValues(condition Condition, environment string) error { + switch condition.Type { + case ConditionOutputEquals, ConditionFileExists, ConditionDirectoryExists, ConditionPathMissing, ConditionFileContentEquals: + return nil + case ConditionOutputContains, ConditionOutputNotContains, ConditionFileContentContains: + if condition.Value == "" { + return fmt.Errorf("value cannot be empty") + } + case ConditionOutputContainsAll: + if len(condition.Values) == 0 { + return fmt.Errorf("values cannot be empty") + } + for _, value := range condition.Values { + if value == "" { + return fmt.Errorf("values cannot contain an empty string") + } + } + case ConditionCWDEquals: + if environment != EnvironmentSimulated { + return fmt.Errorf("cwd_equals requires a simulated environment") + } + return validateAbsolutePath("cwd_equals", condition.Value, true) + case ConditionFileLinesEqual: + if len(condition.Values) == 0 { + return fmt.Errorf("values cannot be empty") + } + for _, value := range condition.Values { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("line values cannot be empty") + } + } + case ConditionFileModeEquals: + return validateMode(condition.Value) + case ConditionFileOwnerEquals: + if strings.TrimSpace(condition.Value) == "" { + return fmt.Errorf("owner cannot be empty") + } + case ConditionProcessStopped, ConditionProcessRunning: + if environment != EnvironmentSimulated { + return fmt.Errorf("%s requires a simulated environment", condition.Type) + } + if condition.PID <= 0 { + return fmt.Errorf("PID must be positive") + } + case ConditionEnvironmentEquals: + if environment != EnvironmentSimulated { + return fmt.Errorf("env_equals requires a simulated environment") + } + name, _, found := strings.Cut(condition.Value, "=") + if !found || !variablePattern.MatchString(name) { + return fmt.Errorf("value must be NAME=value") + } + case ConditionDockerContainerRunning, ConditionDockerContainerStopped, ConditionDockerContainerAbsent: + if environment != EnvironmentDocker { + return fmt.Errorf("%s requires a docker environment", condition.Type) + } + if !ValidDockerLogicalName(condition.Container) { + return fmt.Errorf("container %q must be a lowercase logical name", condition.Container) + } + case ConditionDockerNetworkShared, ConditionDockerNetworkIsolated: + if environment != EnvironmentDocker { + return fmt.Errorf("%s requires a docker environment", condition.Type) + } + if len(condition.Containers) != 2 || condition.Containers[0] == condition.Containers[1] { + return fmt.Errorf("containers must name exactly two different containers") + } + for _, container := range condition.Containers { + if !ValidDockerLogicalName(container) { + return fmt.Errorf("container %q must be a lowercase logical name", container) + } + } + case ConditionDockerNetworkAbsent: + if environment != EnvironmentDocker { + return fmt.Errorf("%s requires a docker environment", condition.Type) + } + if !ValidDockerNetworkName(condition.Network) { + return fmt.Errorf("network %q must be a lowercase logical name other than bridge, default, host, or none", condition.Network) + } + case ConditionDockerContainerCountEqual: + if environment != EnvironmentDocker { + return fmt.Errorf("docker_container_count_equals requires a docker environment") + } + if condition.Count == nil || *condition.Count < 0 { + return fmt.Errorf("count must be a non-negative integer") + } + } + return nil +} + +// UnmarshalJSON retains field presence so validation can reject an unsupported +// field even when its explicit JSON value is empty or zero. Condition owns a +// custom decoder, so it also preserves the catalog's unknown-field rejection. +func (c *Condition) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + present := conditionFields(0) + for name := range fields { + if name == "type" { + continue + } + flag, known := conditionFieldFlags[name] + if !known { + return fmt.Errorf("json: unknown field %q", name) + } + present |= flag + } + type wireCondition Condition + var decoded wireCondition + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *c = Condition(decoded) + c.present = present + return nil +} diff --git a/internal/mission/conditions_test.go b/internal/mission/conditions_test.go new file mode 100644 index 0000000..0ac81e1 --- /dev/null +++ b/internal/mission/conditions_test.go @@ -0,0 +1,47 @@ +package mission + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestMissionValidationRejectsFieldsOutsideConditionContract(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("linux-orientation") + item.Validation.All[0].Path = "/not-used" + if err := validateMission(item); err == nil || !strings.Contains(err.Error(), `type "output_equals" does not support path`) { + t.Fatalf("validateMission() error = %v", err) + } +} + +func TestConditionValidationRejectsExplicitZeroValueFieldsOutsideContract(t *testing.T) { + tests := []struct { + data string + wantErr string + }{ + {data: `{"type":"output_equals","pid":0}`, wantErr: `does not support pid`}, + {data: `{"type":"file_exists","value":""}`, wantErr: `does not support value`}, + {data: `{"type":"path_missing","values":[]}`, wantErr: `does not support values`}, + } + for _, test := range tests { + var condition Condition + if err := json.Unmarshal([]byte(test.data), &condition); err != nil { + t.Fatalf("Unmarshal(%s) error = %v", test.data, err) + } + if err := validateCondition(condition, EnvironmentSimulated); err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Errorf("validateCondition(%s) error = %v, want substring %q", test.data, err, test.wantErr) + } + } +} + +func TestConditionDecoderPreservesUnknownFieldRejection(t *testing.T) { + var condition Condition + err := json.Unmarshal([]byte(`{"type":"output_equals","surprise":0}`), &condition) + if err == nil || !strings.Contains(err.Error(), `unknown field "surprise"`) { + t.Fatalf("Unmarshal() error = %v", err) + } +} diff --git a/internal/mission/curriculum_test.go b/internal/mission/curriculum_test.go new file mode 100644 index 0000000..8f8b0c4 --- /dev/null +++ b/internal/mission/curriculum_test.go @@ -0,0 +1,228 @@ +package mission + +import ( + "strings" + "testing" +) + +func TestAutomationShiftCurriculum(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + wants := []struct { + id string + number int + difficulty string + hints int + }{ + {id: "linux-runbook-runner", number: 26, difficulty: "beginner", hints: 3}, + {id: "linux-vi-first-aid", number: 27, difficulty: "beginner", hints: 3}, + {id: "linux-report-on-repeat", number: 28, difficulty: "intermediate", hints: 4}, + {id: "linux-scope-creep", number: 29, difficulty: "advanced", hints: 5}, + } + for _, want := range wants { + item, found := catalog.Find(want.id) + if !found { + t.Errorf("mission %q missing", want.id) + continue + } + if item.Number != want.number || item.Campaign != "The Automation Shift" || item.Difficulty != want.difficulty || len(item.Hints) != want.hints { + t.Errorf("mission %q curriculum metadata = number %d, campaign %q, difficulty %q, hints %d", item.ID, item.Number, item.Campaign, item.Difficulty, len(item.Hints)) + } + } +} + +func TestRebalancedLinuxCurriculumMetadata(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + + wants := []struct { + id string + campaign string + difficulty string + hints int + }{ + {id: "linux-find-logs", campaign: "First Day", difficulty: "beginner", hints: 3}, + {id: "linux-release-shuffle", campaign: "First Day", difficulty: "beginner", hints: 3}, + {id: "linux-permissions", campaign: "The Logpocalypse", difficulty: "beginner", hints: 3}, + {id: "linux-pipeline-report", campaign: "The Logpocalypse", difficulty: "advanced", hints: 5}, + {id: "linux-production-friday", campaign: "Production Friday", difficulty: "advanced", hints: 5}, + } + for _, want := range wants { + item, found := catalog.Find(want.id) + if !found { + t.Errorf("mission %q missing", want.id) + continue + } + if item.Campaign != want.campaign || item.Difficulty != want.difficulty || len(item.Hints) != want.hints { + t.Errorf("mission %q curriculum metadata = campaign %q, difficulty %q, hints %d; want %q, %q, %d", + item.ID, item.Campaign, item.Difficulty, len(item.Hints), want.campaign, want.difficulty, want.hints) + } + } + + items := catalog.InTrack(TrackLinux) + for _, item := range items { + switch { + case item.Number >= 1 && item.Number <= 6 && item.Campaign != "First Day": + t.Errorf("World 1 mission %d campaign = %q, want First Day", item.Number, item.Campaign) + case item.Number >= 7 && item.Number <= 12 && item.Campaign != "The Logpocalypse": + t.Errorf("World 2 mission %d campaign = %q, want The Logpocalypse", item.Number, item.Campaign) + } + } +} + +func TestBeginnerLinuxExpansionMetadata(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + wants := []struct { + id string + number int + campaign string + }{ + {id: "linux-read-handoff", number: 3, campaign: "First Day"}, + {id: "linux-log-preview", number: 9, campaign: "The Logpocalypse"}, + {id: "linux-error-headcount", number: 14, campaign: "Production Friday"}, + {id: "linux-runbook-runner", number: 26, campaign: "The Automation Shift"}, + } + for _, want := range wants { + item, found := catalog.Find(want.id) + if !found { + t.Errorf("mission %q missing", want.id) + continue + } + if item.Number != want.number || item.Campaign != want.campaign || item.Difficulty != DifficultyBeginner || len(item.Hints) != 3 { + t.Errorf("mission %q metadata = number %d, campaign %q, difficulty %q, hints %d", item.ID, item.Number, item.Campaign, item.Difficulty, len(item.Hints)) + } + } +} + +func TestContainerCensusUsesTypedDockerSetup(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, found := catalog.Find("docker-container-census") + if !found { + t.Fatal("Container Census mission missing") + } + if item.EffectiveTrack() != TrackDocker || item.EffectiveEnvironment() != EnvironmentDocker || item.Docker == nil { + t.Fatalf("docker mission discriminators/setup = %#v", item) + } + if len(item.Docker.Images) != 1 || item.Docker.Images[0].Alias != "fixture" || !strings.Contains(item.Docker.Images[0].Reference, "@sha256:") { + t.Fatalf("docker images = %#v", item.Docker.Images) + } + if len(item.Docker.Containers) != 2 || item.Docker.Containers[0].Name != "api" || item.Docker.Containers[0].State != "stopped" || item.Docker.Containers[1].State != "running" { + t.Fatalf("docker containers = %#v", item.Docker.Containers) + } + if len(item.Validation.All) != 3 || item.Validation.All[2].Count == nil || *item.Validation.All[2].Count != 2 { + t.Fatalf("docker validation = %#v", item.Validation.All) + } +} + +func TestExpandedDockerCurriculumMetadata(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + wants := []string{ + "docker-container-census", + "docker-last-broadcast", + "docker-exit-code-detective", + "docker-quiet-worker", + "docker-recovery-pair", + "docker-shift-handoff", + } + for index, id := range wants { + item, found := catalog.Find(id) + if !found { + t.Errorf("Docker mission %q missing", id) + continue + } + if item.Number != 20+index || item.Campaign != "It Works on My Machine" || item.Difficulty != DifficultyBeginner { + t.Errorf("Docker mission %q metadata = number %d, campaign %q, difficulty %q", id, item.Number, item.Campaign, item.Difficulty) + } + } + diagnostic, _ := catalog.Find("docker-last-broadcast") + if diagnostic.Docker == nil || len(diagnostic.Docker.Containers) != 1 || diagnostic.Docker.Containers[0].Log == "" || diagnostic.Docker.Containers[0].ExitCode == nil || *diagnostic.Docker.Containers[0].ExitCode != 78 { + t.Fatalf("diagnostic Docker setup = %#v", diagnostic.Docker) + } +} + +func TestContainerTriageCurriculum(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + wants := []struct { + id string + number int + difficulty string + hints int + }{ + {id: "docker-janitor-duty", number: 30, difficulty: DifficultyBeginner, hints: 3}, + {id: "docker-tail-end", number: 31, difficulty: DifficultyBeginner, hints: 3}, + {id: "docker-running-isnt-healthy", number: 32, difficulty: DifficultyIntermediate, hints: 3}, + {id: "docker-crash-loop", number: 33, difficulty: DifficultyIntermediate, hints: 3}, + {id: "docker-postmortem-triage", number: 34, difficulty: DifficultyAdvanced, hints: 5}, + } + for _, want := range wants { + item, found := catalog.Find(want.id) + if !found { + t.Errorf("mission %q missing", want.id) + continue + } + if item.Number != want.number || item.Campaign != "Container Triage" || item.EffectiveTrack() != TrackDocker || item.Difficulty != want.difficulty || len(item.Hints) != want.hints { + t.Errorf("mission %q curriculum metadata = number %d, campaign %q, track %q, difficulty %q, hints %d", item.ID, item.Number, item.Campaign, item.EffectiveTrack(), item.Difficulty, len(item.Hints)) + } + } + worlds := catalog.Worlds(TrackDocker) + if len(worlds) < 2 || worlds[1].Name != "Container Triage" || len(worlds[1].Missions) != 5 { + t.Fatalf("Docker worlds = %d, want Container Triage as World 2", len(worlds)) + } + crashLoop, _ := catalog.Find("docker-crash-loop") + payments := crashLoop.Docker.Containers[0] + if payments.Restart != DockerRestartOnFailure || payments.State != DockerStateRunning || payments.ExitCode == nil || *payments.ExitCode == 0 { + t.Fatalf("crash-loop fixture = %#v", payments) + } +} + +func TestNetworkPlumbingCurriculum(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + wants := []struct { + id string + number int + difficulty string + hints int + }{ + {id: "docker-network-census", number: 35, difficulty: DifficultyBeginner, hints: 3}, + {id: "docker-cant-reach-the-db", number: 36, difficulty: DifficultyBeginner, hints: 3}, + {id: "docker-need-to-know", number: 37, difficulty: DifficultyIntermediate, hints: 3}, + {id: "docker-private-channel", number: 38, difficulty: DifficultyIntermediate, hints: 3}, + {id: "docker-segmentation", number: 39, difficulty: DifficultyAdvanced, hints: 5}, + } + for _, want := range wants { + item, found := catalog.Find(want.id) + if !found { + t.Errorf("mission %q missing", want.id) + continue + } + if item.Number != want.number || item.Campaign != "Network Plumbing" || item.EffectiveTrack() != TrackDocker || item.Difficulty != want.difficulty || len(item.Hints) != want.hints { + t.Errorf("mission %q curriculum metadata = number %d, campaign %q, track %q, difficulty %q, hints %d", item.ID, item.Number, item.Campaign, item.EffectiveTrack(), item.Difficulty, len(item.Hints)) + } + if len(item.Docker.Networks) == 0 { + t.Errorf("network mission %q declares no networks", item.ID) + } + } + worlds := catalog.Worlds(TrackDocker) + if len(worlds) != 3 || worlds[2].Name != "Network Plumbing" || len(worlds[2].Missions) != 5 { + t.Fatalf("Docker worlds = %d, want Network Plumbing as World 3", len(worlds)) + } +} diff --git a/internal/mission/docker_rules.go b/internal/mission/docker_rules.go new file mode 100644 index 0000000..fa6bdbe --- /dev/null +++ b/internal/mission/docker_rules.go @@ -0,0 +1,202 @@ +package mission + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + dockerLogicalNamePattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) + dockerImageReferencePattern = regexp.MustCompile(`^[a-z0-9]+(?:[._/-][a-z0-9]+)*(?::[A-Za-z0-9_.-]+)?@sha256:[a-f0-9]{64}$`) +) + +const ( + maxDockerImagesPerMission = 16 + maxDockerContainersPerMission = 32 + maxDockerFixtureLogBytes = 8 * 1024 + maxDockerNetworksPerMission = 8 + maxDockerNetworksPerContainer = 4 +) + +// reservedDockerNetworkNames are Docker's built-in network names. Lab +// networks cannot use them, so a logical name never suggests host or default +// bridge networking. +var reservedDockerNetworkNames = map[string]bool{"bridge": true, "default": true, "host": true, "none": true} + +// ValidDockerNetworkName reports whether value is a safe logical network name +// that does not shadow one of Docker's built-in networks. +func ValidDockerNetworkName(value string) bool { + return ValidDockerLogicalName(value) && !reservedDockerNetworkNames[value] +} + +// ReservedDockerNetworkName reports whether value names a built-in Docker +// network that labs never expose. +func ReservedDockerNetworkName(value string) bool { + return reservedDockerNetworkNames[value] +} + +// ValidDockerLogicalName reports whether value is safe to use as a stable +// mission alias. Runtime adapters use this same rule so catalog-valid content +// cannot become unplayable at environment setup time. +func ValidDockerLogicalName(value string) bool { + return dockerLogicalNamePattern.MatchString(value) +} + +// ValidDockerImageReference accepts only explicit repository references +// pinned to a sha256 digest. +func ValidDockerImageReference(value string) bool { + return dockerImageReferencePattern.MatchString(value) +} + +// ValidateDockerSetup validates the declarative Docker fixture contract shared +// by catalog loading and the runtime adapter. It does not contact Docker. +func ValidateDockerSetup(setup DockerSetup) error { + if len(setup.Images) == 0 { + return fmt.Errorf("docker setup requires at least one image") + } + if len(setup.Images) > maxDockerImagesPerMission { + return fmt.Errorf("docker setup exceeds the %d-image limit", maxDockerImagesPerMission) + } + if len(setup.Containers) == 0 { + return fmt.Errorf("docker setup requires at least one container") + } + if len(setup.Containers) > maxDockerContainersPerMission { + return fmt.Errorf("docker setup exceeds the %d-container limit", maxDockerContainersPerMission) + } + if len(setup.Networks) > maxDockerNetworksPerMission { + return fmt.Errorf("docker setup exceeds the %d-network limit", maxDockerNetworksPerMission) + } + networks := make(map[string]bool, len(setup.Networks)) + for _, network := range setup.Networks { + if !ValidDockerNetworkName(network.Name) { + return fmt.Errorf("docker network name %q must be a lowercase logical name other than bridge, default, host, or none", network.Name) + } + if networks[network.Name] { + return fmt.Errorf("duplicate docker network name %q", network.Name) + } + networks[network.Name] = true + } + images := make(map[string]bool, len(setup.Images)) + for _, image := range setup.Images { + if !ValidDockerLogicalName(image.Alias) { + return fmt.Errorf("docker image alias %q must be a lowercase logical name", image.Alias) + } + if images[image.Alias] { + return fmt.Errorf("duplicate docker image alias %q", image.Alias) + } + if !ValidDockerImageReference(image.Reference) { + return fmt.Errorf("docker image %q reference must be pinned by sha256 digest", image.Alias) + } + images[image.Alias] = true + } + containers := make(map[string]bool, len(setup.Containers)) + for _, container := range setup.Containers { + if !ValidDockerLogicalName(container.Name) { + return fmt.Errorf("docker container name %q must be a lowercase logical name", container.Name) + } + if containers[container.Name] { + return fmt.Errorf("duplicate docker container name %q", container.Name) + } + if !images[container.Image] { + return fmt.Errorf("docker container %q references unknown image alias %q", container.Name, container.Image) + } + switch container.State { + case DockerStateRunning, DockerStateStopped: + default: + return fmt.Errorf("docker container %q has unknown state %q", container.Name, container.State) + } + if len(container.Log) > maxDockerFixtureLogBytes { + return fmt.Errorf("docker container %q log exceeds the %d-byte limit", container.Name, maxDockerFixtureLogBytes) + } + if strings.ContainsRune(container.Log, 0) { + return fmt.Errorf("docker container %q log cannot contain NUL", container.Name) + } + if err := validateDockerFixtureBehavior(container); err != nil { + return err + } + if len(container.Networks) > maxDockerNetworksPerContainer { + return fmt.Errorf("docker container %q exceeds the %d-network limit", container.Name, maxDockerNetworksPerContainer) + } + joined := make(map[string]bool, len(container.Networks)) + for _, network := range container.Networks { + if !networks[network] { + return fmt.Errorf("docker container %q references unknown network %q", container.Name, network) + } + if joined[network] { + return fmt.Errorf("docker container %q joins network %q twice", container.Name, network) + } + joined[network] = true + } + containers[container.Name] = true + } + return nil +} + +// validateDockerFixtureBehavior accepts only the fixed fixture behaviors the +// Docker adapter implements: a long-lived service with optional startup log +// and health probe, a one-shot diagnostic job, or a bounded crash loop. +func validateDockerFixtureBehavior(container DockerContainerSpec) error { + switch container.Health { + case "", DockerHealthHealthy, DockerHealthUnhealthy: + default: + return fmt.Errorf("docker container %q has unknown health %q", container.Name, container.Health) + } + switch container.Restart { + case "", DockerRestartOnFailure: + default: + return fmt.Errorf("docker container %q has unknown restart behavior %q", container.Name, container.Restart) + } + if container.ExitCode == nil { + if container.Restart != "" { + return fmt.Errorf("docker container %q restart behavior requires log and a non-zero exit_code", container.Name) + } + return nil + } + if container.Log == "" { + return fmt.Errorf("docker container %q exit_code requires a log", container.Name) + } + if *container.ExitCode < 0 || *container.ExitCode > 255 { + return fmt.Errorf("docker container %q exit_code must be between 0 and 255", container.Name) + } + if container.Health != "" { + return fmt.Errorf("docker container %q exiting fixture cannot declare health", container.Name) + } + if container.Restart == DockerRestartOnFailure { + if *container.ExitCode == 0 { + return fmt.Errorf("docker container %q restart behavior requires log and a non-zero exit_code", container.Name) + } + if container.State != DockerStateRunning { + return fmt.Errorf("docker container %q crash-loop fixture must use running state", container.Name) + } + return nil + } + if container.State != DockerStateStopped { + return fmt.Errorf("docker container %q diagnostic fixture must use stopped state", container.Name) + } + return nil +} + +func dockerSetupHasNetwork(setup *DockerSetup, name string) bool { + if setup == nil { + return false + } + for _, network := range setup.Networks { + if network.Name == name { + return true + } + } + return false +} + +func dockerSetupHasContainer(setup *DockerSetup, name string) bool { + if setup == nil { + return false + } + for _, container := range setup.Containers { + if container.Name == name { + return true + } + } + return false +} diff --git a/internal/mission/docker_rules_test.go b/internal/mission/docker_rules_test.go new file mode 100644 index 0000000..1e98bad --- /dev/null +++ b/internal/mission/docker_rules_test.go @@ -0,0 +1,431 @@ +package mission + +import ( + "strings" + "testing" +) + +func TestMissionValidationRejectsInvalidDockerDefinitions(t *testing.T) { + tests := []struct { + name string + mutate func(*Mission) + wantErr string + }{ + { + name: "missing setup", + mutate: func(item *Mission) { + item.Docker = nil + }, + wantErr: "requires docker setup", + }, + { + name: "mixed simulated setup", + mutate: func(item *Mission) { + item.Setup.Files = []FileSpec{{Path: "/host-shaped", Content: "no"}} + }, + wantErr: "cannot define simulated setup", + }, + { + name: "duplicate image alias", + mutate: func(item *Mission) { + item.Docker.Images = append(item.Docker.Images, item.Docker.Images[0]) + }, + wantErr: "duplicate docker image alias", + }, + { + name: "unpinned image", + mutate: func(item *Mission) { + item.Docker.Images[0].Reference = "docker.io/library/busybox:latest" + }, + wantErr: "pinned by sha256 digest", + }, + { + name: "duplicate container", + mutate: func(item *Mission) { + item.Docker.Containers = append(item.Docker.Containers, item.Docker.Containers[0]) + }, + wantErr: "duplicate docker container name", + }, + { + name: "unknown image alias", + mutate: func(item *Mission) { + item.Docker.Containers[0].Image = "missing" + }, + wantErr: "references unknown image alias", + }, + { + name: "unknown state", + mutate: func(item *Mission) { + item.Docker.Containers[0].State = "paused" + }, + wantErr: "unknown state", + }, + { + name: "diagnostic exit code without log", + mutate: func(item *Mission) { + exitCode := 1 + item.Docker.Containers[0].ExitCode = &exitCode + }, + wantErr: "exit_code requires a log", + }, + { + name: "service log too large", + mutate: func(item *Mission) { + item.Docker.Containers[1].Log = strings.Repeat("x", maxDockerFixtureLogBytes+1) + }, + wantErr: "log exceeds", + }, + { + name: "unknown health", + mutate: func(item *Mission) { + item.Docker.Containers[1].Health = "starting" + }, + wantErr: "unknown health", + }, + { + name: "health on exiting fixture", + mutate: func(item *Mission) { + exitCode := 0 + item.Docker.Containers[0].Log = "complete" + item.Docker.Containers[0].ExitCode = &exitCode + item.Docker.Containers[0].Health = DockerHealthHealthy + }, + wantErr: "cannot declare health", + }, + { + name: "unknown restart behavior", + mutate: func(item *Mission) { + item.Docker.Containers[1].Restart = "always" + }, + wantErr: "unknown restart behavior", + }, + { + name: "restart without crash", + mutate: func(item *Mission) { + item.Docker.Containers[1].Restart = DockerRestartOnFailure + }, + wantErr: "requires log and a non-zero exit_code", + }, + { + name: "restart with successful exit", + mutate: func(item *Mission) { + exitCode := 0 + item.Docker.Containers[1].Log = "complete" + item.Docker.Containers[1].ExitCode = &exitCode + item.Docker.Containers[1].Restart = DockerRestartOnFailure + }, + wantErr: "requires log and a non-zero exit_code", + }, + { + name: "crash loop declared stopped", + mutate: func(item *Mission) { + exitCode := 1 + item.Docker.Containers[0].Log = "FATAL" + item.Docker.Containers[0].ExitCode = &exitCode + item.Docker.Containers[0].Restart = DockerRestartOnFailure + }, + wantErr: "crash-loop fixture must use running state", + }, + { + name: "reserved network name", + mutate: func(item *Mission) { + item.Docker.Networks = []DockerNetworkSpec{{Name: "host"}} + }, + wantErr: "other than bridge, default, host, or none", + }, + { + name: "duplicate network", + mutate: func(item *Mission) { + item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}, {Name: "app-net"}} + }, + wantErr: "duplicate docker network name", + }, + { + name: "too many networks", + mutate: func(item *Mission) { + for index := 0; index <= maxDockerNetworksPerMission; index++ { + item.Docker.Networks = append(item.Docker.Networks, DockerNetworkSpec{Name: "net-" + strings.Repeat("a", index+1)}) + } + }, + wantErr: "network limit", + }, + { + name: "container joins unknown network", + mutate: func(item *Mission) { + item.Docker.Containers[0].Networks = []string{"missing"} + }, + wantErr: "references unknown network", + }, + { + name: "container joins network twice", + mutate: func(item *Mission) { + item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}} + item.Docker.Containers[0].Networks = []string{"app-net", "app-net"} + }, + wantErr: "joins network \"app-net\" twice", + }, + { + name: "container joins too many networks", + mutate: func(item *Mission) { + for _, name := range []string{"a-net", "b-net", "c-net", "d-net", "e-net"} { + item.Docker.Networks = append(item.Docker.Networks, DockerNetworkSpec{Name: name}) + item.Docker.Containers[0].Networks = append(item.Docker.Containers[0].Networks, name) + } + }, + wantErr: "exceeds the 4-network limit", + }, + { + name: "shared network with one container", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api"}} + }, + wantErr: "exactly two different containers", + }, + { + name: "isolated container compared with itself", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkIsolated, Containers: []string{"api", "api"}} + }, + wantErr: "exactly two different containers", + }, + { + name: "shared network with unknown container", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api", "missing"}} + }, + wantErr: "unknown docker container", + }, + { + name: "shared network condition with network field", + mutate: func(item *Mission) { + item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}} + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api", "metrics"}, Network: "app-net"} + }, + wantErr: "does not support network", + }, + { + name: "unknown absent network", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkAbsent, Network: "missing"} + }, + wantErr: "unknown docker network", + }, + { + name: "reserved absent network", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkAbsent, Network: "none"} + }, + wantErr: "other than bridge, default, host, or none", + }, + { + name: "unknown absent validation container", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerContainerAbsent, Container: "missing"} + }, + wantErr: "unknown docker container", + }, + { + name: "absent condition with count", + mutate: func(item *Mission) { + count := 1 + item.Validation.All[0] = Condition{Type: ConditionDockerContainerAbsent, Container: "api", Count: &count} + }, + wantErr: "does not support count", + }, + { + name: "diagnostic exit code outside range", + mutate: func(item *Mission) { + exitCode := 256 + item.Docker.Containers[0].Log = "diagnostic" + item.Docker.Containers[0].ExitCode = &exitCode + }, + wantErr: "between 0 and 255", + }, + { + name: "diagnostic negative exit code", + mutate: func(item *Mission) { + exitCode := -1 + item.Docker.Containers[0].Log = "diagnostic" + item.Docker.Containers[0].ExitCode = &exitCode + }, + wantErr: "between 0 and 255", + }, + { + name: "diagnostic log too large", + mutate: func(item *Mission) { + exitCode := 1 + item.Docker.Containers[0].Log = strings.Repeat("x", maxDockerFixtureLogBytes+1) + item.Docker.Containers[0].ExitCode = &exitCode + }, + wantErr: "log exceeds", + }, + { + name: "diagnostic log contains NUL", + mutate: func(item *Mission) { + exitCode := 1 + item.Docker.Containers[0].Log = "bad\x00log" + item.Docker.Containers[0].ExitCode = &exitCode + }, + wantErr: "cannot contain NUL", + }, + { + name: "diagnostic fixture declared running", + mutate: func(item *Mission) { + exitCode := 0 + item.Docker.Containers[1].Log = "complete" + item.Docker.Containers[1].ExitCode = &exitCode + }, + wantErr: "must use stopped state", + }, + { + name: "unknown validation container", + mutate: func(item *Mission) { + item.Validation.All[0].Container = "missing" + }, + wantErr: "unknown docker container", + }, + { + name: "unknown stopped validation container", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerContainerStopped, Container: "missing"} + }, + wantErr: "unknown docker container", + }, + { + name: "missing count", + mutate: func(item *Mission) { + item.Validation.All[2].Count = nil + }, + wantErr: "count must be a non-negative integer", + }, + { + name: "negative count", + mutate: func(item *Mission) { + count := -1 + item.Validation.All[2].Count = &count + }, + wantErr: "count must be a non-negative integer", + }, + { + name: "running condition with unrelated value", + mutate: func(item *Mission) { + item.Validation.All[0].Value = "api" + }, + wantErr: "does not support value", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("docker-container-census") + test.mutate(&item) + err = validateMission(item) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("validateMission() error = %v, want substring %q", err, test.wantErr) + } + }) + } +} + +func TestMissionValidationAcceptsDockerFixtureBehaviors(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("docker-container-census") + failure := 3 + item.Docker.Containers = []DockerContainerSpec{ + {Name: "api", Image: "fixture", State: DockerStateStopped, Log: "INFO ready", Health: DockerHealthHealthy}, + {Name: "metrics", Image: "fixture", State: DockerStateRunning, Health: DockerHealthUnhealthy}, + {Name: "payments", Image: "fixture", State: DockerStateRunning, Log: "FATAL key missing", ExitCode: &failure, Restart: DockerRestartOnFailure}, + } + item.Validation.All = append(item.Validation.All, Condition{Type: ConditionDockerContainerAbsent, Container: "payments"}) + if err := validateMission(item); err != nil { + t.Fatalf("validateMission() error = %v", err) + } +} + +func TestMissionValidationAcceptsDockerNetworks(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("docker-container-census") + item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}, {Name: "db-net"}} + item.Docker.Containers[0].Networks = []string{"app-net", "db-net"} + item.Docker.Containers[1].Networks = []string{"app-net"} + item.Validation.All = append(item.Validation.All, + Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api", "metrics"}}, + Condition{Type: ConditionDockerNetworkIsolated, Containers: []string{"metrics", "api"}}, + Condition{Type: ConditionDockerNetworkAbsent, Network: "db-net"}, + ) + if err := validateMission(item); err != nil { + t.Fatalf("validateMission() error = %v", err) + } + for _, name := range []string{"bridge", "default", "host", "none"} { + if ValidDockerNetworkName(name) || !ReservedDockerNetworkName(name) { + t.Errorf("network name %q was not treated as reserved", name) + } + } +} + +func TestDockerNetworkFieldsAreDeepCopied(t *testing.T) { + item := Mission{ + Docker: &DockerSetup{ + Networks: []DockerNetworkSpec{{Name: "app-net"}}, + Containers: []DockerContainerSpec{{Name: "api", Networks: []string{"app-net"}}}, + }, + Validation: Validation{All: []Condition{{Type: ConditionDockerNetworkShared, Containers: []string{"api", "db"}}}}, + } + cloned := cloneMission(item) + cloned.Docker.Networks[0].Name = "changed" + cloned.Docker.Containers[0].Networks[0] = "changed" + cloned.Validation.All[0].Containers[0] = "changed" + if item.Docker.Networks[0].Name != "app-net" || item.Docker.Containers[0].Networks[0] != "app-net" || item.Validation.All[0].Containers[0] != "api" { + t.Fatalf("cloneMission shared network data: %#v", item) + } +} + +func TestMissionValidationRejectsDockerConditionOnSimulatedMission(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("linux-orientation") + for _, condition := range []Condition{ + {Type: ConditionDockerContainerRunning, Container: "api"}, + {Type: ConditionDockerContainerAbsent, Container: "api"}, + {Type: ConditionDockerNetworkShared, Containers: []string{"api", "db"}}, + {Type: ConditionDockerNetworkIsolated, Containers: []string{"api", "db"}}, + {Type: ConditionDockerNetworkAbsent, Network: "app-net"}, + } { + item.Validation.All = []Condition{condition} + if err := validateMission(item); err == nil || !strings.Contains(err.Error(), "requires a docker environment") { + t.Fatalf("validateMission(%s) error = %v", condition.Type, err) + } + } +} + +func TestDockerSetupResourceLimits(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("docker-container-census") + + tooManyImages := *item.Docker + tooManyImages.Images = make([]DockerImageSpec, maxDockerImagesPerMission+1) + if err := ValidateDockerSetup(tooManyImages); err == nil || !strings.Contains(err.Error(), "image limit") { + t.Fatalf("ValidateDockerSetup(images) error = %v", err) + } + + tooManyContainers := *item.Docker + tooManyContainers.Containers = make([]DockerContainerSpec, maxDockerContainersPerMission+1) + if err := ValidateDockerSetup(tooManyContainers); err == nil || !strings.Contains(err.Error(), "container limit") { + t.Fatalf("ValidateDockerSetup(containers) error = %v", err) + } +} diff --git a/internal/mission/mission.go b/internal/mission/mission.go index 4717efd..f1163b1 100644 --- a/internal/mission/mission.go +++ b/internal/mission/mission.go @@ -1,10 +1,5 @@ package mission -import ( - "encoding/json" - "fmt" -) - const ( TrackLinux = "linux" TrackDocker = "docker" @@ -187,35 +182,6 @@ type Condition struct { present conditionFields } -// UnmarshalJSON retains field presence so validation can reject an unsupported -// field even when its explicit JSON value is empty or zero. Condition owns a -// custom decoder, so it also preserves the catalog's unknown-field rejection. -func (c *Condition) UnmarshalJSON(data []byte) error { - var fields map[string]json.RawMessage - if err := json.Unmarshal(data, &fields); err != nil { - return err - } - present := conditionFields(0) - for name := range fields { - if name == "type" { - continue - } - flag, known := conditionFieldFlags[name] - if !known { - return fmt.Errorf("json: unknown field %q", name) - } - present |= flag - } - type wireCondition Condition - var decoded wireCondition - if err := json.Unmarshal(data, &decoded); err != nil { - return err - } - *c = Condition(decoded) - c.present = present - return nil -} - type Rewards struct { XP int `json:"xp"` HintPenalty int `json:"hint_penalty"` diff --git a/internal/mission/validate.go b/internal/mission/validate.go new file mode 100644 index 0000000..ed5b592 --- /dev/null +++ b/internal/mission/validate.go @@ -0,0 +1,145 @@ +package mission + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + missionIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + commandNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + variablePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +) + +// validateMission checks one decoded mission in a fixed order, so the first +// reported problem is stable. +func validateMission(item Mission) error { + for _, check := range []func(Mission) error{validateIdentity, validateGuidance, validateLab, validateOutcomes} { + if err := check(item); err != nil { + return err + } + } + return nil +} + +// validateIdentity checks placement, narrative, difficulty, and rewards. +func validateIdentity(item Mission) error { + if !missionIDPattern.MatchString(item.ID) { + return fmt.Errorf("id %q must use lowercase words separated by hyphens", item.ID) + } + track := item.EffectiveTrack() + switch track { + case TrackLinux, TrackDocker: + default: + return fmt.Errorf("unknown track %q", track) + } + environment := item.EffectiveEnvironment() + switch environment { + case EnvironmentSimulated, EnvironmentDocker: + default: + return fmt.Errorf("unknown environment %q", environment) + } + if (track == TrackDocker) != (environment == EnvironmentDocker) { + return fmt.Errorf("track %q cannot use environment %q", track, environment) + } + for name, value := range map[string]string{ + "title": item.Title, "campaign": item.Campaign, "difficulty": item.Difficulty, + "story": item.Story, "objective": item.Objective, "explanation": item.Explanation, + } { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is required", name) + } + } + if item.Number < 1 { + return fmt.Errorf("number must be positive") + } + switch item.Difficulty { + case DifficultyBeginner, DifficultyIntermediate, DifficultyAdvanced: + default: + return fmt.Errorf("unknown difficulty %q", item.Difficulty) + } + if item.Rewards.XP <= 0 || item.Rewards.HintPenalty < 0 { + return fmt.Errorf("XP must be positive and hint penalty cannot be negative") + } + return nil +} + +// validateGuidance checks suggested commands and hints. +func validateGuidance(item Mission) error { + if len(item.SuggestedCommands) == 0 { + return fmt.Errorf("at least one suggested command is required") + } + seenCommands := make(map[string]bool, len(item.SuggestedCommands)) + for index, command := range item.SuggestedCommands { + if !commandNamePattern.MatchString(command) { + return fmt.Errorf("suggested command %d %q must be a lowercase command name", index+1, command) + } + if seenCommands[command] { + return fmt.Errorf("duplicate suggested command %q", command) + } + seenCommands[command] = true + } + if len(item.Hints) < 1 || len(item.Hints) > 5 { + return fmt.Errorf("missions require between 1 and 5 hints") + } + for index, hint := range item.Hints { + if strings.TrimSpace(hint) == "" { + return fmt.Errorf("hint %d is empty", index+1) + } + } + return nil +} + +// validateLab checks that the setup matches the mission's environment: +// simulated missions declare a virtual lab, Docker missions a Docker setup. +func validateLab(item Mission) error { + if item.EffectiveEnvironment() == EnvironmentSimulated { + if item.Docker != nil { + return fmt.Errorf("simulated mission cannot define docker setup") + } + if err := validateAbsolutePath("start_dir", item.StartDir, true); err != nil { + return err + } + return validateSetup(item.Setup, item.StartDir) + } + if item.StartDir != "" { + return fmt.Errorf("docker mission cannot define start_dir") + } + if !setupEmpty(item.Setup) { + return fmt.Errorf("docker mission cannot define simulated setup") + } + if item.Docker == nil { + return fmt.Errorf("docker mission requires docker setup") + } + return ValidateDockerSetup(*item.Docker) +} + +// validateOutcomes checks every condition and that each container or network +// it names is declared by the setup. +func validateOutcomes(item Mission) error { + if len(item.Validation.All) == 0 { + return fmt.Errorf("at least one validation condition is required") + } + for index, condition := range item.Validation.All { + if err := validateCondition(condition, item.EffectiveEnvironment()); err != nil { + return fmt.Errorf("validation condition %d: %w", index+1, err) + } + if err := validateConditionReferences(condition, item.Docker); err != nil { + return fmt.Errorf("validation condition %d: %w", index+1, err) + } + } + return nil +} + +func validateConditionReferences(condition Condition, setup *DockerSetup) error { + for _, container := range append([]string{condition.Container}, condition.Containers...) { + if container != "" && !dockerSetupHasContainer(setup, container) { + return fmt.Errorf("unknown docker container %q", container) + } + } + if condition.Network != "" && !dockerSetupHasNetwork(setup, condition.Network) { + return fmt.Errorf("unknown docker network %q", condition.Network) + } + return nil +} diff --git a/internal/mission/validate_setup.go b/internal/mission/validate_setup.go new file mode 100644 index 0000000..7943cf0 --- /dev/null +++ b/internal/mission/validate_setup.go @@ -0,0 +1,142 @@ +package mission + +import ( + "fmt" + "path" + "strconv" + "strings" +) + +func setupEmpty(setup Setup) bool { + return len(setup.Directories) == 0 && len(setup.Files) == 0 && len(setup.Processes) == 0 && + len(setup.Environment) == 0 && len(setup.Archives) == 0 +} + +func validateSetup(setup Setup, startDir string) error { + entries := map[string]string{"/": "directory"} + addEntry := func(name, kind string) error { + if existing, found := entries[name]; found { + return fmt.Errorf("setup path %s is both %s and %s", name, existing, kind) + } + entries[name] = kind + return nil + } + for _, directory := range setup.Directories { + if err := validateAbsolutePath("directory", directory.Path, true); err != nil { + return err + } + if err := validateMode(directory.Mode); err != nil { + return fmt.Errorf("directory %s: %w", directory.Path, err) + } + if err := addEntry(directory.Path, "directory"); err != nil { + return err + } + } + for _, file := range setup.Files { + if err := validateAbsolutePath("file", file.Path, false); err != nil { + return err + } + if err := validateMode(file.Mode); err != nil { + return fmt.Errorf("file %s: %w", file.Path, err) + } + if err := addEntry(file.Path, "file"); err != nil { + return err + } + } + for _, archive := range setup.Archives { + if err := validateAbsolutePath("archive", archive.Path, false); err != nil { + return err + } + if err := addEntry(archive.Path, "archive"); err != nil { + return err + } + archiveEntries := make(map[string]bool) + for _, entry := range archive.Entries { + cleaned, err := validateArchiveEntry(entry.Path) + if err != nil { + return fmt.Errorf("archive %s entry %q: %w", archive.Path, entry.Path, err) + } + if archiveEntries[cleaned] { + return fmt.Errorf("archive %s contains duplicate entry %s", archive.Path, cleaned) + } + archiveEntries[cleaned] = true + if err := validateMode(entry.Mode); err != nil { + return fmt.Errorf("archive %s entry %s: %w", archive.Path, entry.Path, err) + } + } + } + for entryPath, kind := range entries { + if entryPath == "/" { + continue + } + for parent := path.Dir(entryPath); parent != "/"; parent = path.Dir(parent) { + if parentKind, exists := entries[parent]; exists && parentKind != "directory" { + return fmt.Errorf("%s %s is nested beneath non-directory %s", kind, entryPath, parent) + } + } + } + for name := range setup.Environment { + if !variablePattern.MatchString(name) { + return fmt.Errorf("invalid environment variable name %q", name) + } + } + pids := make(map[int]bool) + for _, process := range setup.Processes { + if process.PID <= 0 || strings.TrimSpace(process.Command) == "" { + return fmt.Errorf("process PID and command are required") + } + if pids[process.PID] { + return fmt.Errorf("duplicate process PID %d", process.PID) + } + pids[process.PID] = true + } + startExists := startDir == "/" + if kind, exists := entries[startDir]; exists { + if kind != "directory" { + return fmt.Errorf("start_dir %s is not a directory", startDir) + } + startExists = true + } + for entryPath := range entries { + if strings.HasPrefix(entryPath, startDir+"/") { + startExists = true + break + } + } + if !startExists { + return fmt.Errorf("start_dir %s is not created by setup", startDir) + } + return nil +} + +func validateAbsolutePath(kind, value string, allowRoot bool) error { + if !strings.HasPrefix(value, "/") || path.Clean(value) != value { + return fmt.Errorf("%s path %q must be absolute and clean", kind, value) + } + if value == "/" && !allowRoot { + return fmt.Errorf("%s path cannot be /", kind) + } + return nil +} + +func validateMode(value string) error { + if value == "" { + return nil + } + mode, err := strconv.ParseUint(value, 8, 12) + if err != nil || mode > 0o7777 { + return fmt.Errorf("invalid mode %q", value) + } + return nil +} + +func validateArchiveEntry(value string) (string, error) { + if value == "" || strings.HasPrefix(value, "/") { + return "", fmt.Errorf("path must be relative") + } + cleaned := path.Clean(value) + if cleaned != value || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", fmt.Errorf("path must be clean and remain inside the archive") + } + return cleaned, nil +} diff --git a/internal/mission/validate_test.go b/internal/mission/validate_test.go new file mode 100644 index 0000000..a6e3125 --- /dev/null +++ b/internal/mission/validate_test.go @@ -0,0 +1,138 @@ +package mission + +import ( + "strings" + "testing" +) + +func TestMissionValidationRejectsUnknownTrackAndEnvironment(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + base, _ := catalog.Find("linux-orientation") + tests := []struct { + name string + mutate func(*Mission) + wantErr string + }{ + { + name: "track", + mutate: func(item *Mission) { item.Track = "cloud" }, + wantErr: `unknown track "cloud"`, + }, + { + name: "environment", + mutate: func(item *Mission) { item.Environment = "host" }, + wantErr: `unknown environment "host"`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + item := base + test.mutate(&item) + if err := validateMission(item); err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("validateMission() error = %v, want substring %q", err, test.wantErr) + } + }) + } +} + +func TestMissionValidationRejectsUnknownDifficultyAndInvalidHintCount(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + base, _ := catalog.Find("linux-orientation") + tests := []struct { + name string + mutate func(*Mission) + wantErr string + }{ + { + name: "unknown difficulty", + mutate: func(item *Mission) { item.Difficulty = "expert-ish" }, + wantErr: "unknown difficulty", + }, + { + name: "no hints", + mutate: func(item *Mission) { item.Hints = nil }, + wantErr: "between 1 and 5 hints", + }, + { + name: "too many hints", + mutate: func(item *Mission) { item.Hints = make([]string, 6) }, + wantErr: "between 1 and 5 hints", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + item := base + test.mutate(&item) + if err := validateMission(item); err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("validateMission() error = %v, want substring %q", err, test.wantErr) + } + }) + } +} + +func TestMissionValidationRejectsInvalidSuggestedCommands(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + base, _ := catalog.Find("linux-orientation") + tests := []struct { + name string + commands []string + wantErr string + }{ + {name: "missing", commands: nil, wantErr: "at least one suggested command"}, + {name: "blank", commands: []string{""}, wantErr: "lowercase command name"}, + {name: "syntax instead of command name", commands: []string{"grep ERROR"}, wantErr: "lowercase command name"}, + {name: "duplicate", commands: []string{"pwd", "pwd"}, wantErr: "duplicate suggested command"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + item := base + item.SuggestedCommands = test.commands + if err := validateMission(item); err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("validateMission() error = %v, want substring %q", err, test.wantErr) + } + }) + } +} + +func TestMissionDecoderRejectsUnknownFields(t *testing.T) { + _, err := decodeMission([]byte(`{"id":"test","surprise":true}`)) + if err == nil { + t.Fatal("unknown mission field was accepted") + } +} + +func TestMissionValidationRejectsUnsafeArchiveEntries(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, found := catalog.Find("linux-archive-rescue") + if !found { + t.Fatal("archive mission missing") + } + item.Setup.Archives[0].Entries[0].Path = "../../outside" + if err := validateMission(item); err == nil { + t.Fatal("unsafe archive entry was accepted") + } +} + +func TestMissionValidationRejectsConflictingSetupPaths(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("1") + item.Setup.Files = append(item.Setup.Files, FileSpec{Path: "/home/operator", Content: "conflict"}) + if err := validateMission(item); err == nil { + t.Fatal("directory/file path conflict was accepted") + } +} diff --git a/internal/profile/achievements.go b/internal/profile/achievements.go new file mode 100644 index 0000000..689d2bf --- /dev/null +++ b/internal/profile/achievements.go @@ -0,0 +1,81 @@ +package profile + +import ( + "slices" + "time" +) + +type Achievement struct { + ID string + Title string + Description string +} + +const ( + AchievementFirstFix = "first-fix" + AchievementPipeDream = "pipe-dream" + AchievementCommandCollector = "command-collector" + AchievementSelfReliant = "self-reliant" + AchievementBossSlayer = "boss-slayer" + AchievementLinuxCompletionist = "linux-completionist" +) + +var achievements = []Achievement{ + {ID: AchievementFirstFix, Title: "First Fix", Description: "Complete your first mission."}, + {ID: AchievementPipeDream, Title: "Pipe Dream", Description: "Complete a successful pipeline with at least three commands."}, + {ID: AchievementCommandCollector, Title: "Command Collector", Description: "Successfully practice ten different commands."}, + {ID: AchievementSelfReliant, Title: "Self-Reliant", Description: "Complete five missions without using a hint."}, + {ID: AchievementBossSlayer, Title: "Boss Slayer", Description: "Complete an advanced mission."}, + {ID: AchievementLinuxCompletionist, Title: "Linux Completionist", Description: "Complete every Linux mission."}, +} + +func (p Profile) HintFreeCompletions() int { + total := 0 + for _, completion := range p.Completed { + if completion.HintsUsed == 0 { + total++ + } + } + return total +} + +func (p *Profile) UnlockAchievement(id string, now time.Time) (Achievement, bool) { + definition, exists := FindAchievement(id) + if !exists { + return Achievement{}, false + } + ensureMap(&p.Unlocked) + if _, unlocked := p.Unlocked[id]; unlocked { + return definition, false + } + p.Unlocked[id] = now + return definition, true +} + +func (p Profile) HasAchievement(id string) bool { + _, unlocked := p.Unlocked[id] + return unlocked +} + +func (p Profile) AchievementCount() int { + total := 0 + for _, achievement := range achievements { + if p.HasAchievement(achievement.ID) { + total++ + } + } + return total +} + +func AchievementDefinitions() []Achievement { + return slices.Clone(achievements) +} + +func FindAchievement(id string) (Achievement, bool) { + for _, achievement := range achievements { + if achievement.ID == id { + return achievement, true + } + } + return Achievement{}, false +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 6801318..978da8e 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -1,13 +1,8 @@ package profile import ( - "encoding/json" - "errors" "fmt" "maps" - "os" - "path/filepath" - "slices" "sort" "strings" "time" @@ -40,42 +35,6 @@ type Completion struct { CompletedAt time.Time `json:"completed_at"` } -type Achievement struct { - ID string - Title string - Description string -} - -const ( - AchievementFirstFix = "first-fix" - AchievementPipeDream = "pipe-dream" - AchievementCommandCollector = "command-collector" - AchievementSelfReliant = "self-reliant" - AchievementBossSlayer = "boss-slayer" - AchievementLinuxCompletionist = "linux-completionist" -) - -var achievements = []Achievement{ - {ID: AchievementFirstFix, Title: "First Fix", Description: "Complete your first mission."}, - {ID: AchievementPipeDream, Title: "Pipe Dream", Description: "Complete a successful pipeline with at least three commands."}, - {ID: AchievementCommandCollector, Title: "Command Collector", Description: "Successfully practice ten different commands."}, - {ID: AchievementSelfReliant, Title: "Self-Reliant", Description: "Complete five missions without using a hint."}, - {ID: AchievementBossSlayer, Title: "Boss Slayer", Description: "Complete an advanced mission."}, - {ID: AchievementLinuxCompletionist, Title: "Linux Completionist", Description: "Complete every Linux mission."}, -} - -var rankThresholds = []struct { - name string - xp int -}{ - {name: "Intern", xp: 0}, - {name: "Operator", xp: 100}, - {name: "Junior Sysadmin", xp: 250}, - {name: "Sysadmin", xp: 450}, - {name: "SRE", xp: 650}, - {name: "Senior SRE", xp: 1100}, -} - func New(name string) Profile { return Profile{ Version: currentVersion, @@ -227,190 +186,3 @@ func (p Profile) ActiveHints() int { } return total } - -func (p Profile) HintFreeCompletions() int { - total := 0 - for _, completion := range p.Completed { - if completion.HintsUsed == 0 { - total++ - } - } - return total -} - -func (p *Profile) UnlockAchievement(id string, now time.Time) (Achievement, bool) { - definition, exists := FindAchievement(id) - if !exists { - return Achievement{}, false - } - ensureMap(&p.Unlocked) - if _, unlocked := p.Unlocked[id]; unlocked { - return definition, false - } - p.Unlocked[id] = now - return definition, true -} - -func (p Profile) HasAchievement(id string) bool { - _, unlocked := p.Unlocked[id] - return unlocked -} - -func (p Profile) AchievementCount() int { - total := 0 - for _, achievement := range achievements { - if p.HasAchievement(achievement.ID) { - total++ - } - } - return total -} - -func AchievementDefinitions() []Achievement { - return slices.Clone(achievements) -} - -func FindAchievement(id string) (Achievement, bool) { - for _, achievement := range achievements { - if achievement.ID == id { - return achievement, true - } - } - return Achievement{}, false -} - -func (p Profile) Level() int { return p.XP/100 + 1 } - -func (p Profile) Rank() string { - for index := len(rankThresholds) - 1; index >= 0; index-- { - if p.XP >= rankThresholds[index].xp { - return rankThresholds[index].name - } - } - return rankThresholds[0].name -} - -func (p Profile) NextRank() (string, int, bool) { - for _, threshold := range rankThresholds[1:] { - if p.XP < threshold.xp { - return threshold.name, threshold.xp - p.XP, true - } - } - return "", 0, false -} - -type Store struct { - path string - name func() string -} - -func DefaultStore() (Store, error) { - root := strings.TrimSpace(os.Getenv("OPSQUEST_HOME")) - if root == "" { - configDir, err := os.UserConfigDir() - if err != nil { - return Store{}, fmt.Errorf("locate config directory: %w", err) - } - root = filepath.Join(configDir, "opsquest") - } - return Store{ - path: filepath.Join(root, "profile.json"), - name: func() string { - if value := strings.TrimSpace(os.Getenv("OPSQUEST_PLAYER")); value != "" { - return value - } - if value := strings.TrimSpace(os.Getenv("USER")); value != "" { - return value - } - return "operator" - }, - }, nil -} - -func NewStore(path, playerName string) Store { - return Store{path: path, name: func() string { return playerName }} -} - -func (s Store) Path() string { return s.path } - -func (s Store) Load() (Profile, error) { - data, err := os.ReadFile(s.path) - if errors.Is(err, os.ErrNotExist) { - return New(s.name()), nil - } - if err != nil { - return Profile{}, fmt.Errorf("read profile: %w", err) - } - var player Profile - if err := json.Unmarshal(data, &player); err != nil { - return Profile{}, fmt.Errorf("decode profile: %w", err) - } - if player.Version > currentVersion { - return Profile{}, fmt.Errorf("profile version %d is newer than this OpsQuest build", player.Version) - } - player.Normalize() - return player, nil -} - -func (s Store) Save(player Profile) error { - // Validate before Normalize so controls and oversized user input cannot be - // silently converted into a different persisted display name. Load and New - // have already normalized compatibility/default values at their boundaries. - if err := ValidateName(player.Name); err != nil { - return fmt.Errorf("invalid profile name: %w", err) - } - // Profile is passed by value, but its maps still alias the caller. Clone - // them before compatibility normalization so saving never mutates the live - // session (notably when completed-mission replay hints are omitted on disk). - player = player.clone() - player.Normalize() - data, err := json.MarshalIndent(player, "", " ") - if err != nil { - return fmt.Errorf("encode profile: %w", err) - } - data = append(data, '\n') - directory := filepath.Dir(s.path) - if err := os.MkdirAll(directory, 0o700); err != nil { - return fmt.Errorf("create profile directory: %w", err) - } - temporary, err := os.CreateTemp(directory, ".profile-*.tmp") - if err != nil { - return fmt.Errorf("create temporary profile: %w", err) - } - temporaryName := temporary.Name() - removeTemporary := true - defer func() { - _ = temporary.Close() - if removeTemporary { - _ = os.Remove(temporaryName) - } - }() - if err := temporary.Chmod(0o600); err != nil { - return fmt.Errorf("secure temporary profile: %w", err) - } - if _, err := temporary.Write(data); err != nil { - return fmt.Errorf("write profile: %w", err) - } - if err := temporary.Sync(); err != nil { - return fmt.Errorf("sync profile: %w", err) - } - if err := temporary.Close(); err != nil { - return fmt.Errorf("close profile: %w", err) - } - if err := os.Rename(temporaryName, s.path); err != nil { - return fmt.Errorf("replace profile: %w", err) - } - removeTemporary = false - return nil -} - -func (s Store) Reset() (bool, error) { - err := os.Remove(s.path) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("remove profile: %w", err) - } - return true, nil -} diff --git a/internal/profile/rank.go b/internal/profile/rank.go new file mode 100644 index 0000000..4bb08f9 --- /dev/null +++ b/internal/profile/rank.go @@ -0,0 +1,33 @@ +package profile + +var rankThresholds = []struct { + name string + xp int +}{ + {name: "Intern", xp: 0}, + {name: "Operator", xp: 100}, + {name: "Junior Sysadmin", xp: 250}, + {name: "Sysadmin", xp: 450}, + {name: "SRE", xp: 650}, + {name: "Senior SRE", xp: 1100}, +} + +func (p Profile) Level() int { return p.XP/100 + 1 } + +func (p Profile) Rank() string { + for index := len(rankThresholds) - 1; index >= 0; index-- { + if p.XP >= rankThresholds[index].xp { + return rankThresholds[index].name + } + } + return rankThresholds[0].name +} + +func (p Profile) NextRank() (string, int, bool) { + for _, threshold := range rankThresholds[1:] { + if p.XP < threshold.xp { + return threshold.name, threshold.xp - p.XP, true + } + } + return "", 0, false +} diff --git a/internal/profile/store.go b/internal/profile/store.go new file mode 100644 index 0000000..d807ce1 --- /dev/null +++ b/internal/profile/store.go @@ -0,0 +1,126 @@ +package profile + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +type Store struct { + path string + name func() string +} + +func DefaultStore() (Store, error) { + root := strings.TrimSpace(os.Getenv("OPSQUEST_HOME")) + if root == "" { + configDir, err := os.UserConfigDir() + if err != nil { + return Store{}, fmt.Errorf("locate config directory: %w", err) + } + root = filepath.Join(configDir, "opsquest") + } + return Store{ + path: filepath.Join(root, "profile.json"), + name: func() string { + if value := strings.TrimSpace(os.Getenv("OPSQUEST_PLAYER")); value != "" { + return value + } + if value := strings.TrimSpace(os.Getenv("USER")); value != "" { + return value + } + return "operator" + }, + }, nil +} + +func NewStore(path, playerName string) Store { + return Store{path: path, name: func() string { return playerName }} +} + +func (s Store) Path() string { return s.path } + +func (s Store) Load() (Profile, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return New(s.name()), nil + } + if err != nil { + return Profile{}, fmt.Errorf("read profile: %w", err) + } + var player Profile + if err := json.Unmarshal(data, &player); err != nil { + return Profile{}, fmt.Errorf("decode profile: %w", err) + } + if player.Version > currentVersion { + return Profile{}, fmt.Errorf("profile version %d is newer than this OpsQuest build", player.Version) + } + player.Normalize() + return player, nil +} + +func (s Store) Save(player Profile) error { + // Validate before Normalize so controls and oversized user input cannot be + // silently converted into a different persisted display name. Load and New + // have already normalized compatibility/default values at their boundaries. + if err := ValidateName(player.Name); err != nil { + return fmt.Errorf("invalid profile name: %w", err) + } + // Profile is passed by value, but its maps still alias the caller. Clone + // them before compatibility normalization so saving never mutates the live + // session (notably when completed-mission replay hints are omitted on disk). + player = player.clone() + player.Normalize() + data, err := json.MarshalIndent(player, "", " ") + if err != nil { + return fmt.Errorf("encode profile: %w", err) + } + data = append(data, '\n') + directory := filepath.Dir(s.path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return fmt.Errorf("create profile directory: %w", err) + } + temporary, err := os.CreateTemp(directory, ".profile-*.tmp") + if err != nil { + return fmt.Errorf("create temporary profile: %w", err) + } + temporaryName := temporary.Name() + removeTemporary := true + defer func() { + _ = temporary.Close() + if removeTemporary { + _ = os.Remove(temporaryName) + } + }() + if err := temporary.Chmod(0o600); err != nil { + return fmt.Errorf("secure temporary profile: %w", err) + } + if _, err := temporary.Write(data); err != nil { + return fmt.Errorf("write profile: %w", err) + } + if err := temporary.Sync(); err != nil { + return fmt.Errorf("sync profile: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close profile: %w", err) + } + if err := os.Rename(temporaryName, s.path); err != nil { + return fmt.Errorf("replace profile: %w", err) + } + removeTemporary = false + return nil +} + +func (s Store) Reset() (bool, error) { + err := os.Remove(s.path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("remove profile: %w", err) + } + return true, nil +} diff --git a/internal/sandbox/archive_metadata.go b/internal/sandbox/archive_metadata.go new file mode 100644 index 0000000..70904a7 --- /dev/null +++ b/internal/sandbox/archive_metadata.go @@ -0,0 +1,91 @@ +package sandbox + +import ( + "fmt" + "strings" +) + +func cloneArchives(source map[string]Archive) map[string]Archive { + return cloneMap(source) +} + +func validateArchiveMetadata(archives map[string]Archive) error { + entryCount := 0 + contentBytes := 0 + for archivePath, archive := range archives { + for _, entry := range archive.Entries { + if entryCount == maxVirtualArchiveEntries { + return fmt.Errorf("virtual archive entry limit of %d exceeded", maxVirtualArchiveEntries) + } + entryCount++ + if len(entry.Content) > maxVirtualFileBytes { + return fmt.Errorf("%s: archive entry %q exceeds the %d KiB content limit", archivePath, entry.Path, maxVirtualFileBytes/1024) + } + if len(entry.Content) > maxVirtualArchiveBytes-contentBytes { + return fmt.Errorf("virtual archive content limit of %d MiB exceeded", maxVirtualArchiveBytes/(1024*1024)) + } + contentBytes += len(entry.Content) + } + } + return nil +} + +func (s *Sandbox) planArchiveReplacement(archivePath string, archive Archive) (map[string]Archive, error) { + archives := cloneArchives(s.Archives) + archives[archivePath] = archive + if err := validateArchiveMetadata(archives); err != nil { + return nil, err + } + return archives, nil +} + +func (s *Sandbox) planArchiveCopy(source, destination string) (map[string]Archive, error) { + archives := cloneArchives(s.Archives) + copies := make(map[string]Archive) + for archivePath, archive := range s.Archives { + if archivePath == source || strings.HasPrefix(archivePath, source+"/") { + copies[destination+strings.TrimPrefix(archivePath, source)] = archive + } + } + paths, err := s.FS.Descendants(source, true) + if err != nil { + return nil, err + } + for _, sourcePath := range paths { + relative := strings.TrimPrefix(sourcePath, source) + delete(archives, destination+relative) + } + for archivePath, archive := range copies { + archives[archivePath] = archive + } + if err := validateArchiveMetadata(archives); err != nil { + return nil, err + } + return archives, nil +} + +func (s *Sandbox) moveArchiveMetadata(source, destination string) { + moves := make(map[string]Archive) + for archivePath, archive := range s.Archives { + if archivePath == source || strings.HasPrefix(archivePath, source+"/") { + moves[destination+strings.TrimPrefix(archivePath, source)] = archive + } + } + s.removeArchiveMetadata(destination) + s.removeArchiveMetadata(source) + for archivePath, archive := range moves { + s.Archives[archivePath] = archive + } +} + +func (s *Sandbox) removeArchiveMetadata(target string) { + removeArchiveMetadata(s.Archives, target) +} + +func removeArchiveMetadata(archives map[string]Archive, target string) { + for archivePath := range archives { + if archivePath == target || strings.HasPrefix(archivePath, target+"/") { + delete(archives, archivePath) + } + } +} diff --git a/internal/sandbox/command_line.go b/internal/sandbox/command_line.go new file mode 100644 index 0000000..0e8b904 --- /dev/null +++ b/internal/sandbox/command_line.go @@ -0,0 +1,132 @@ +package sandbox + +import ( + "fmt" +) + +type shellWord struct { + value string + glob bool +} + +type pipelineStage struct { + args []shellWord + inputPath string + outputPath string + append bool +} + +type commandLine struct { + stages []pipelineStage +} + +type expandedPipelineStage struct { + args []string + inputPath string + outputPath string + append bool +} + +type expandedCommandLine struct { + stages []expandedPipelineStage +} + +func parseCommandLine(tokens []token) (commandLine, error) { + if len(tokens) == 0 { + return commandLine{}, nil + } + parsed := commandLine{} + stage := pipelineStage{} + for i := 0; i < len(tokens); i++ { + current := tokens[i] + switch current.kind { + case wordToken: + stage.args = append(stage.args, shellWord{value: current.value, glob: current.glob}) + case pipeToken: + if len(stage.args) == 0 { + return commandLine{}, fmt.Errorf("unexpected pipe") + } + if len(parsed.stages) == maxPipelineStages-1 { + return commandLine{}, fmt.Errorf("pipeline stage limit of %d exceeded", maxPipelineStages) + } + parsed.stages = append(parsed.stages, stage) + stage = pipelineStage{} + case redirectToken, appendToken, inputToken: + if i+1 >= len(tokens) || tokens[i+1].kind != wordToken { + return commandLine{}, fmt.Errorf("redirection requires a file") + } + i++ + file := tokens[i].value + if current.kind == inputToken { + if stage.inputPath != "" { + return commandLine{}, fmt.Errorf("multiple input redirections") + } + stage.inputPath = file + } else { + if stage.outputPath != "" { + return commandLine{}, fmt.Errorf("multiple output redirections") + } + stage.outputPath = file + stage.append = current.kind == appendToken + } + } + } + if len(stage.args) == 0 { + if len(parsed.stages) > 0 { + return commandLine{}, fmt.Errorf("pipeline cannot end with a pipe") + } + return commandLine{}, fmt.Errorf("redirection requires a command") + } + if len(parsed.stages) == maxPipelineStages { + return commandLine{}, fmt.Errorf("pipeline stage limit of %d exceeded", maxPipelineStages) + } + parsed.stages = append(parsed.stages, stage) + return parsed, nil +} + +func (s *Sandbox) expandCommandLine(parsed commandLine) (expandedCommandLine, error) { + expanded := expandedCommandLine{stages: make([]expandedPipelineStage, 0, len(parsed.stages))} + argumentCount := 0 + tokenBytes := 0 + consumeTokenBytes := func(value string) error { + if len(value) > maxExpandedTokenBytes-tokenBytes { + return fmt.Errorf("expanded command exceeds the %d KiB token limit", maxExpandedTokenBytes/1024) + } + tokenBytes += len(value) + return nil + } + for _, stage := range parsed.stages { + args := make([]string, 0, len(stage.args)) + for _, word := range stage.args { + values := []string{word.value} + if word.glob { + if matches := s.FS.Glob(s.CWD, word.value); len(matches) > 0 { + values = matches + } + } + for _, value := range values { + if argumentCount == maxExpandedArguments { + return expandedCommandLine{}, fmt.Errorf("expanded command exceeds the %d-argument limit", maxExpandedArguments) + } + if err := consumeTokenBytes(value); err != nil { + return expandedCommandLine{}, err + } + argumentCount++ + args = append(args, value) + } + } + if err := consumeTokenBytes(stage.inputPath); err != nil { + return expandedCommandLine{}, err + } + if err := consumeTokenBytes(stage.outputPath); err != nil { + return expandedCommandLine{}, err + } + expanded.stages = append(expanded.stages, expandedPipelineStage{ + args: args, + inputPath: stage.inputPath, + outputPath: stage.outputPath, + append: stage.append, + }) + } + return expanded, nil +} diff --git a/internal/sandbox/commands_system.go b/internal/sandbox/commands_archive.go similarity index 55% rename from internal/sandbox/commands_system.go rename to internal/sandbox/commands_archive.go index 2e9d8b3..6b27cb2 100644 --- a/internal/sandbox/commands_system.go +++ b/internal/sandbox/commands_archive.go @@ -3,187 +3,11 @@ package sandbox import ( "fmt" "path" - "slices" - "sort" - "strconv" "strings" "github.com/aleksandergregersen/opsquest/internal/mission" ) -func (s *Sandbox) cmdFind(context *executionContext, args []string) (string, error) { - var roots []string - index := 0 - for index < len(args) && !strings.HasPrefix(args[index], "-") { - roots = append(roots, args[index]) - index++ - } - if len(roots) == 0 { - roots = []string{"."} - } - - namePattern := "" - caseInsensitive := false - entryType := "" - var execArgs []string - for index < len(args) { - switch args[index] { - case "-name", "-iname": - caseInsensitive = args[index] == "-iname" - if index+1 >= len(args) { - return "", fmt.Errorf("%s requires a pattern", args[index]) - } - index++ - namePattern = args[index] - case "-type": - if index+1 >= len(args) { - return "", fmt.Errorf("-type requires f or d") - } - index++ - entryType = args[index] - if entryType != "f" && entryType != "d" { - return "", fmt.Errorf("unsupported type %q", entryType) - } - case "-print": - // Printing is the default action. - case "-exec": - index++ - start := index - for index < len(args) && args[index] != ";" { - index++ - } - if index >= len(args) { - return "", fmt.Errorf("-exec must end with \\;") - } - execArgs = slices.Clone(args[start:index]) - if len(execArgs) == 0 { - return "", fmt.Errorf("-exec requires a command") - } - default: - return "", fmt.Errorf("unsupported expression %s", args[index]) - } - index++ - } - - var output commandOutputBuffer - for _, root := range roots { - rootAbs := s.Resolve(root) - items, err := s.FS.Descendants(rootAbs, true) - if err != nil { - return "", err - } - for _, candidate := range items { - entry, _ := s.FS.Entry(candidate) - if entryType == "f" && entry.Kind != Regular { - continue - } - if entryType == "d" && entry.Kind != Directory { - continue - } - if namePattern != "" { - candidateName, pattern := path.Base(candidate), namePattern - if caseInsensitive { - candidateName, pattern = strings.ToLower(candidateName), strings.ToLower(pattern) - } - matched, err := path.Match(pattern, candidateName) - if err != nil { - return "", fmt.Errorf("invalid name pattern: %w", err) - } - if !matched { - continue - } - } - display := displayFindPath(root, rootAbs, candidate) - if len(execArgs) == 0 { - output.WriteString(display + "\n") - if err := output.Err(); err != nil { - return "", err - } - continue - } - command := slices.Clone(execArgs) - for position, arg := range command { - if arg == "{}" { - command[position] = display - } - } - result, err := s.run(context, command, "") - if err != nil { - return "", fmt.Errorf("-exec %s: %w", command[0], err) - } - output.WriteString(result) - if err := output.Err(); err != nil { - return "", err - } - } - } - return output.Result() -} - -func displayFindPath(root, rootAbs, candidate string) string { - if strings.HasPrefix(root, "/") { - return candidate - } - rel := strings.TrimPrefix(candidate, rootAbs) - if rel == "" { - return path.Clean(root) - } - if root == "." || root == "./" { - return "." + rel - } - return strings.TrimSuffix(root, "/") + rel -} - -func (s *Sandbox) cmdPS(args []string) (string, error) { - for _, arg := range args { - if arg != "-e" && arg != "-ef" && arg != "aux" && arg != "-A" { - return "", fmt.Errorf("unsupported option %s", arg) - } - } - pids := make([]int, 0, len(s.Processes)) - for pid, process := range s.Processes { - if process.Running { - pids = append(pids, pid) - } - } - sort.Ints(pids) - var output commandOutputBuffer - output.WriteString(" PID COMMAND\n") - for _, pid := range pids { - output.WriteString(fmt.Sprintf("%5d %s\n", pid, s.Processes[pid].Command)) - } - return output.Result() -} - -func (s *Sandbox) cmdKill(args []string) (string, error) { - var pids []string - for _, arg := range args { - if strings.HasPrefix(arg, "-") { - signal := strings.TrimPrefix(arg, "-") - if signal != "9" && signal != "15" && signal != "TERM" && signal != "KILL" { - return "", fmt.Errorf("unsupported signal %s", arg) - } - continue - } - pids = append(pids, arg) - } - if len(pids) == 0 { - return "", fmt.Errorf("missing PID") - } - for _, value := range pids { - pid, err := strconv.Atoi(value) - if err != nil { - return "", fmt.Errorf("%s: PID must be a number", value) - } - process, exists := s.Processes[pid] - if !exists || !process.Running { - return "", fmt.Errorf("%d: no such process", pid) - } - process.Running = false - } - return "", nil -} - func (s *Sandbox) cmdTar(args []string) (string, error) { if len(args) == 0 { return "", fmt.Errorf("missing operation") @@ -412,45 +236,3 @@ func (s *Sandbox) cmdGzip(command string, args []string) (string, error) { } return "", nil } - -func (s *Sandbox) cmdExport(args []string) (string, error) { - if len(args) == 0 { - return s.cmdEnv(nil) - } - environment := cloneEnvironment(s.Env) - for _, assignment := range args { - key, value, found := strings.Cut(assignment, "=") - if !found || !validVariableName(key) { - return "", fmt.Errorf("expected NAME=value, got %q", assignment) - } - environment[key] = value - } - if err := validateEnvironment(environment); err != nil { - return "", err - } - s.Env = environment - return "", nil -} - -func (s *Sandbox) cmdEnv(args []string) (string, error) { - if len(args) > 0 { - return "", fmt.Errorf("running a command through env is not supported in this lab") - } - var output commandOutputBuffer - for _, key := range sortedKeys(s.Env) { - output.WriteString(key + "=" + s.Env[key] + "\n") - } - return output.Result() -} - -func validVariableName(name string) bool { - if name == "" { - return false - } - for index, char := range name { - if !(char == '_' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || index > 0 && char >= '0' && char <= '9') { - return false - } - } - return true -} diff --git a/internal/sandbox/commands_system_regression_test.go b/internal/sandbox/commands_archive_regression_test.go similarity index 100% rename from internal/sandbox/commands_system_regression_test.go rename to internal/sandbox/commands_archive_regression_test.go diff --git a/internal/sandbox/commands_columns.go b/internal/sandbox/commands_columns.go new file mode 100644 index 0000000..80532b2 --- /dev/null +++ b/internal/sandbox/commands_columns.go @@ -0,0 +1,85 @@ +package sandbox + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +var awkPrintField = regexp.MustCompile(`^\{\s*print\s+\$([0-9]+)\s*\}$`) + +func (s *Sandbox) cmdAwk(args []string, stdin string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("missing program") + } + match := awkPrintField.FindStringSubmatch(args[0]) + if match == nil { + return "", fmt.Errorf("this lab supports awk programs like '{print $3}'") + } + field, _ := strconv.Atoi(match[1]) + if field < 1 { + return "", fmt.Errorf("field numbers start at 1") + } + inputs, err := s.readInputs(args[1:], stdin) + if err != nil { + return "", err + } + return selectField(inputs, field, strings.Fields) +} + +func (s *Sandbox) cmdCut(args []string, stdin string) (string, error) { + delimiter := "\t" + field := 0 + var files []string + for index := 0; index < len(args); index++ { + switch { + case args[index] == "-d": + if index+1 >= len(args) { + return "", fmt.Errorf("-d requires a delimiter") + } + index++ + delimiter = args[index] + case strings.HasPrefix(args[index], "-d") && len(args[index]) > 2: + delimiter = strings.TrimPrefix(args[index], "-d") + case args[index] == "-f": + if index+1 >= len(args) { + return "", fmt.Errorf("-f requires a field") + } + index++ + field, _ = strconv.Atoi(args[index]) + case strings.HasPrefix(args[index], "-f"): + field, _ = strconv.Atoi(strings.TrimPrefix(args[index], "-f")) + default: + if strings.HasPrefix(args[index], "-") { + return "", fmt.Errorf("unknown option %s", args[index]) + } + files = append(files, args[index]) + } + } + if field < 1 { + return "", fmt.Errorf("select a positive field with -f") + } + if delimiter == "" { + return "", fmt.Errorf("delimiter cannot be empty") + } + inputs, err := s.readInputs(files, stdin) + if err != nil { + return "", err + } + return selectField(inputs, field, func(line string) []string { return strings.Split(line, delimiter) }) +} + +func selectField(inputs []namedText, field int, split func(string) []string) (string, error) { + var output commandOutputBuffer + for _, input := range inputs { + for _, line := range textLines(input.text) { + fields := split(line) + if field <= len(fields) { + output.WriteString(fields[field-1]) + } + output.WriteByte('\n') + } + } + return output.Result() +} diff --git a/internal/sandbox/commands_env.go b/internal/sandbox/commands_env.go new file mode 100644 index 0000000..7f38b21 --- /dev/null +++ b/internal/sandbox/commands_env.go @@ -0,0 +1,48 @@ +package sandbox + +import ( + "fmt" + "strings" +) + +func (s *Sandbox) cmdExport(args []string) (string, error) { + if len(args) == 0 { + return s.cmdEnv(nil) + } + environment := cloneEnvironment(s.Env) + for _, assignment := range args { + key, value, found := strings.Cut(assignment, "=") + if !found || !validVariableName(key) { + return "", fmt.Errorf("expected NAME=value, got %q", assignment) + } + environment[key] = value + } + if err := validateEnvironment(environment); err != nil { + return "", err + } + s.Env = environment + return "", nil +} + +func (s *Sandbox) cmdEnv(args []string) (string, error) { + if len(args) > 0 { + return "", fmt.Errorf("running a command through env is not supported in this lab") + } + var output commandOutputBuffer + for _, key := range sortedKeys(s.Env) { + output.WriteString(key + "=" + s.Env[key] + "\n") + } + return output.Result() +} + +func validVariableName(name string) bool { + if name == "" { + return false + } + for index, char := range name { + if !(char == '_' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || index > 0 && char >= '0' && char <= '9') { + return false + } + } + return true +} diff --git a/internal/sandbox/commands_files.go b/internal/sandbox/commands_files.go index 7f9e5a7..7d55b73 100644 --- a/internal/sandbox/commands_files.go +++ b/internal/sandbox/commands_files.go @@ -3,7 +3,6 @@ package sandbox import ( "fmt" "path" - "sort" "strconv" "strings" ) @@ -292,32 +291,3 @@ func (s *Sandbox) cmdChown(args []string) (string, error) { } return "", nil } - -func sortedKeys[V any](items map[string]V) []string { - keys := make([]string, 0, len(items)) - for key := range items { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -// parseShortOptions handles the shared, deliberately small combined-short-flag -// syntax used by teaching commands such as ls, du, sort, and tr. -func parseShortOptions(args []string, valid string, dashIsOperand bool) (string, []string, error) { - var options strings.Builder - operands := make([]string, 0, len(args)) - for _, arg := range args { - if !strings.HasPrefix(arg, "-") || dashIsOperand && arg == "-" { - operands = append(operands, arg) - continue - } - for _, option := range strings.TrimPrefix(arg, "-") { - if !strings.ContainsRune(valid, option) { - return "", nil, fmt.Errorf("unknown option -%c", option) - } - options.WriteRune(option) - } - } - return options.String(), operands, nil -} diff --git a/internal/sandbox/commands_find.go b/internal/sandbox/commands_find.go new file mode 100644 index 0000000..145022d --- /dev/null +++ b/internal/sandbox/commands_find.go @@ -0,0 +1,131 @@ +package sandbox + +import ( + "fmt" + "path" + "slices" + "strings" +) + +func (s *Sandbox) cmdFind(context *executionContext, args []string) (string, error) { + var roots []string + index := 0 + for index < len(args) && !strings.HasPrefix(args[index], "-") { + roots = append(roots, args[index]) + index++ + } + if len(roots) == 0 { + roots = []string{"."} + } + + namePattern := "" + caseInsensitive := false + entryType := "" + var execArgs []string + for index < len(args) { + switch args[index] { + case "-name", "-iname": + caseInsensitive = args[index] == "-iname" + if index+1 >= len(args) { + return "", fmt.Errorf("%s requires a pattern", args[index]) + } + index++ + namePattern = args[index] + case "-type": + if index+1 >= len(args) { + return "", fmt.Errorf("-type requires f or d") + } + index++ + entryType = args[index] + if entryType != "f" && entryType != "d" { + return "", fmt.Errorf("unsupported type %q", entryType) + } + case "-print": + // Printing is the default action. + case "-exec": + index++ + start := index + for index < len(args) && args[index] != ";" { + index++ + } + if index >= len(args) { + return "", fmt.Errorf("-exec must end with \\;") + } + execArgs = slices.Clone(args[start:index]) + if len(execArgs) == 0 { + return "", fmt.Errorf("-exec requires a command") + } + default: + return "", fmt.Errorf("unsupported expression %s", args[index]) + } + index++ + } + + var output commandOutputBuffer + for _, root := range roots { + rootAbs := s.Resolve(root) + items, err := s.FS.Descendants(rootAbs, true) + if err != nil { + return "", err + } + for _, candidate := range items { + entry, _ := s.FS.Entry(candidate) + if entryType == "f" && entry.Kind != Regular { + continue + } + if entryType == "d" && entry.Kind != Directory { + continue + } + if namePattern != "" { + candidateName, pattern := path.Base(candidate), namePattern + if caseInsensitive { + candidateName, pattern = strings.ToLower(candidateName), strings.ToLower(pattern) + } + matched, err := path.Match(pattern, candidateName) + if err != nil { + return "", fmt.Errorf("invalid name pattern: %w", err) + } + if !matched { + continue + } + } + display := displayFindPath(root, rootAbs, candidate) + if len(execArgs) == 0 { + output.WriteString(display + "\n") + if err := output.Err(); err != nil { + return "", err + } + continue + } + command := slices.Clone(execArgs) + for position, arg := range command { + if arg == "{}" { + command[position] = display + } + } + result, err := s.run(context, command, "") + if err != nil { + return "", fmt.Errorf("-exec %s: %w", command[0], err) + } + output.WriteString(result) + if err := output.Err(); err != nil { + return "", err + } + } + } + return output.Result() +} + +func displayFindPath(root, rootAbs, candidate string) string { + if strings.HasPrefix(root, "/") { + return candidate + } + rel := strings.TrimPrefix(candidate, rootAbs) + if rel == "" { + return path.Clean(root) + } + if root == "." || root == "./" { + return "." + rel + } + return strings.TrimSuffix(root, "/") + rel +} diff --git a/internal/sandbox/commands_grep.go b/internal/sandbox/commands_grep.go new file mode 100644 index 0000000..4f45dbb --- /dev/null +++ b/internal/sandbox/commands_grep.go @@ -0,0 +1,142 @@ +package sandbox + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +func (s *Sandbox) cmdGrep(args []string, stdin string) (string, error) { + recursive, namesOnly, lineNumbers := false, false, false + insensitive, invert, fixed, countOnly, wholeWord := false, false, false, false, false + var operands []string + optionsDone := false + for _, arg := range args { + if !optionsDone && arg == "--" { + optionsDone = true + continue + } + if !optionsDone && strings.HasPrefix(arg, "-") && arg != "-" { + for _, option := range strings.TrimPrefix(arg, "-") { + switch option { + case 'r', 'R': + recursive = true + case 'l': + namesOnly = true + case 'n': + lineNumbers = true + case 'i': + insensitive = true + case 'v': + invert = true + case 'F': + fixed = true + case 'c': + countOnly = true + case 'w': + wholeWord = true + case 'E': + // Go regular expressions already use extended-style syntax. + default: + return "", fmt.Errorf("unknown option -%c", option) + } + } + continue + } + optionsDone = true + operands = append(operands, arg) + } + if len(operands) == 0 { + return "", fmt.Errorf("missing search pattern") + } + pattern := operands[0] + if fixed { + pattern = regexp.QuoteMeta(pattern) + } + if wholeWord { + pattern = `\b(?:` + pattern + `)\b` + } + if insensitive { + pattern = "(?i)" + pattern + } + matcher, err := regexp.Compile(pattern) + if err != nil { + return "", fmt.Errorf("invalid pattern: %w", err) + } + + fileArgs := operands[1:] + inputs := make([]namedText, 0) + if len(fileArgs) == 0 { + inputs = append(inputs, namedText{text: stdin}) + } else { + for _, name := range fileArgs { + resolved := s.Resolve(name) + entry, exists := s.FS.Entry(resolved) + if !exists { + return "", fmt.Errorf("%s: no such file or directory", name) + } + if entry.Kind == Directory { + if !recursive { + return "", fmt.Errorf("%s: is a directory", name) + } + paths, _ := s.FS.Descendants(resolved, false) + for _, candidate := range paths { + item, _ := s.FS.Entry(candidate) + if item.Kind != Regular { + continue + } + display := candidate + if !strings.HasPrefix(name, "/") { + rel := strings.TrimPrefix(candidate, resolved) + display = strings.TrimSuffix(name, "/") + rel + } + inputs = append(inputs, namedText{name: display, text: item.Content}) + } + } else { + inputs = append(inputs, namedText{name: name, text: entry.Content}) + } + } + } + + showNames := len(inputs) > 1 || recursive + var output commandOutputBuffer + for _, input := range inputs { + matchedFile := false + matchCount := 0 + for index, line := range textLines(input.text) { + matched := matcher.MatchString(line) + if invert { + matched = !matched + } + if !matched { + continue + } + matchCount++ + if namesOnly { + if input.name != "" && !matchedFile { + output.WriteString(input.name + "\n") + matchedFile = true + } + continue + } + if countOnly { + continue + } + if showNames && input.name != "" { + output.WriteString(input.name + ":") + } + if lineNumbers { + output.WriteString(strconv.Itoa(index+1) + ":") + } + output.WriteString(line + "\n") + } + if countOnly && !namesOnly { + if showNames && input.name != "" { + output.WriteString(input.name + ":") + } + output.WriteString(strconv.Itoa(matchCount) + "\n") + } + } + return output.Result() +} diff --git a/internal/sandbox/commands_process.go b/internal/sandbox/commands_process.go new file mode 100644 index 0000000..06fe84e --- /dev/null +++ b/internal/sandbox/commands_process.go @@ -0,0 +1,58 @@ +package sandbox + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +func (s *Sandbox) cmdPS(args []string) (string, error) { + for _, arg := range args { + if arg != "-e" && arg != "-ef" && arg != "aux" && arg != "-A" { + return "", fmt.Errorf("unsupported option %s", arg) + } + } + pids := make([]int, 0, len(s.Processes)) + for pid, process := range s.Processes { + if process.Running { + pids = append(pids, pid) + } + } + sort.Ints(pids) + var output commandOutputBuffer + output.WriteString(" PID COMMAND\n") + for _, pid := range pids { + output.WriteString(fmt.Sprintf("%5d %s\n", pid, s.Processes[pid].Command)) + } + return output.Result() +} + +func (s *Sandbox) cmdKill(args []string) (string, error) { + var pids []string + for _, arg := range args { + if strings.HasPrefix(arg, "-") { + signal := strings.TrimPrefix(arg, "-") + if signal != "9" && signal != "15" && signal != "TERM" && signal != "KILL" { + return "", fmt.Errorf("unsupported signal %s", arg) + } + continue + } + pids = append(pids, arg) + } + if len(pids) == 0 { + return "", fmt.Errorf("missing PID") + } + for _, value := range pids { + pid, err := strconv.Atoi(value) + if err != nil { + return "", fmt.Errorf("%s: PID must be a number", value) + } + process, exists := s.Processes[pid] + if !exists || !process.Running { + return "", fmt.Errorf("%d: no such process", pid) + } + process.Running = false + } + return "", nil +} diff --git a/internal/sandbox/commands_transform.go b/internal/sandbox/commands_sed.go similarity index 80% rename from internal/sandbox/commands_transform.go rename to internal/sandbox/commands_sed.go index 06384ec..b5469ca 100644 --- a/internal/sandbox/commands_transform.go +++ b/internal/sandbox/commands_sed.go @@ -323,79 +323,3 @@ func addSedResultBytes(total, additional int) (int, error) { func isSedReplacementNameByte(value byte) bool { return value == '_' || value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' } - -func (s *Sandbox) cmdTr(args []string, stdin string) (string, error) { - options, operands, err := parseShortOptions(args, "ds", true) - if err != nil { - return "", err - } - deleteSet, squeeze := strings.ContainsRune(options, 'd'), strings.ContainsRune(options, 's') - if len(operands) == 0 || (!deleteSet && !squeeze && len(operands) < 2) || len(operands) > 2 { - return "", fmt.Errorf("usage: tr [-ds] SET1 [SET2]") - } - set1, err := expandCharacterSet(operands[0]) - if err != nil { - return "", err - } - set2 := []rune(nil) - if len(operands) == 2 { - set2, err = expandCharacterSet(operands[1]) - if err != nil { - return "", err - } - } - deleteMap := make(map[rune]bool, len(set1)) - translations := make(map[rune]rune, len(set1)) - for index, char := range set1 { - deleteMap[char] = true - if !deleteSet && len(set2) > 0 { - targetIndex := min(index, len(set2)-1) - translations[char] = set2[targetIndex] - } - } - squeezeSet := set2 - if deleteSet || len(squeezeSet) == 0 { - squeezeSet = set1 - } - squeezeMap := make(map[rune]bool, len(squeezeSet)) - for _, char := range squeezeSet { - squeezeMap[char] = true - } - var output commandOutputBuffer - var previous rune - havePrevious := false - for _, char := range stdin { - if deleteSet && deleteMap[char] { - continue - } - if translated, exists := translations[char]; exists { - char = translated - } - if squeeze && havePrevious && char == previous && squeezeMap[char] { - continue - } - output.WriteRune(char) - previous, havePrevious = char, true - } - return output.Result() -} - -func expandCharacterSet(value string) ([]rune, error) { - value = strings.NewReplacer(`\n`, "\n", `\t`, "\t", `\r`, "\r").Replace(value) - runes := []rune(value) - result := make([]rune, 0, len(runes)) - for index := 0; index < len(runes); index++ { - if index+2 < len(runes) && runes[index+1] == '-' { - if runes[index] > runes[index+2] { - return nil, fmt.Errorf("descending character range %c-%c", runes[index], runes[index+2]) - } - for char := runes[index]; char <= runes[index+2]; char++ { - result = append(result, char) - } - index += 2 - continue - } - result = append(result, runes[index]) - } - return result, nil -} diff --git a/internal/sandbox/commands_text.go b/internal/sandbox/commands_text.go index 0c89221..529e19f 100644 --- a/internal/sandbox/commands_text.go +++ b/internal/sandbox/commands_text.go @@ -2,7 +2,6 @@ package sandbox import ( "fmt" - "regexp" "slices" "sort" "strconv" @@ -93,140 +92,6 @@ func (s *Sandbox) cmdHeadTail(args []string, stdin string, head bool) (string, e return output.Result() } -func (s *Sandbox) cmdGrep(args []string, stdin string) (string, error) { - recursive, namesOnly, lineNumbers := false, false, false - insensitive, invert, fixed, countOnly, wholeWord := false, false, false, false, false - var operands []string - optionsDone := false - for _, arg := range args { - if !optionsDone && arg == "--" { - optionsDone = true - continue - } - if !optionsDone && strings.HasPrefix(arg, "-") && arg != "-" { - for _, option := range strings.TrimPrefix(arg, "-") { - switch option { - case 'r', 'R': - recursive = true - case 'l': - namesOnly = true - case 'n': - lineNumbers = true - case 'i': - insensitive = true - case 'v': - invert = true - case 'F': - fixed = true - case 'c': - countOnly = true - case 'w': - wholeWord = true - case 'E': - // Go regular expressions already use extended-style syntax. - default: - return "", fmt.Errorf("unknown option -%c", option) - } - } - continue - } - optionsDone = true - operands = append(operands, arg) - } - if len(operands) == 0 { - return "", fmt.Errorf("missing search pattern") - } - pattern := operands[0] - if fixed { - pattern = regexp.QuoteMeta(pattern) - } - if wholeWord { - pattern = `\b(?:` + pattern + `)\b` - } - if insensitive { - pattern = "(?i)" + pattern - } - matcher, err := regexp.Compile(pattern) - if err != nil { - return "", fmt.Errorf("invalid pattern: %w", err) - } - - fileArgs := operands[1:] - inputs := make([]namedText, 0) - if len(fileArgs) == 0 { - inputs = append(inputs, namedText{text: stdin}) - } else { - for _, name := range fileArgs { - resolved := s.Resolve(name) - entry, exists := s.FS.Entry(resolved) - if !exists { - return "", fmt.Errorf("%s: no such file or directory", name) - } - if entry.Kind == Directory { - if !recursive { - return "", fmt.Errorf("%s: is a directory", name) - } - paths, _ := s.FS.Descendants(resolved, false) - for _, candidate := range paths { - item, _ := s.FS.Entry(candidate) - if item.Kind != Regular { - continue - } - display := candidate - if !strings.HasPrefix(name, "/") { - rel := strings.TrimPrefix(candidate, resolved) - display = strings.TrimSuffix(name, "/") + rel - } - inputs = append(inputs, namedText{name: display, text: item.Content}) - } - } else { - inputs = append(inputs, namedText{name: name, text: entry.Content}) - } - } - } - - showNames := len(inputs) > 1 || recursive - var output commandOutputBuffer - for _, input := range inputs { - matchedFile := false - matchCount := 0 - for index, line := range textLines(input.text) { - matched := matcher.MatchString(line) - if invert { - matched = !matched - } - if !matched { - continue - } - matchCount++ - if namesOnly { - if input.name != "" && !matchedFile { - output.WriteString(input.name + "\n") - matchedFile = true - } - continue - } - if countOnly { - continue - } - if showNames && input.name != "" { - output.WriteString(input.name + ":") - } - if lineNumbers { - output.WriteString(strconv.Itoa(index+1) + ":") - } - output.WriteString(line + "\n") - } - if countOnly && !namesOnly { - if showNames && input.name != "" { - output.WriteString(input.name + ":") - } - output.WriteString(strconv.Itoa(matchCount) + "\n") - } - } - return output.Result() -} - func (s *Sandbox) cmdSort(args []string, stdin string) (string, error) { options, files, err := parseShortOptions(args, "run", false) if err != nil { @@ -353,83 +218,6 @@ func (s *Sandbox) cmdWC(args []string, stdin string) (string, error) { return output.Result() } -var awkPrintField = regexp.MustCompile(`^\{\s*print\s+\$([0-9]+)\s*\}$`) - -func (s *Sandbox) cmdAwk(args []string, stdin string) (string, error) { - if len(args) == 0 { - return "", fmt.Errorf("missing program") - } - match := awkPrintField.FindStringSubmatch(args[0]) - if match == nil { - return "", fmt.Errorf("this lab supports awk programs like '{print $3}'") - } - field, _ := strconv.Atoi(match[1]) - if field < 1 { - return "", fmt.Errorf("field numbers start at 1") - } - inputs, err := s.readInputs(args[1:], stdin) - if err != nil { - return "", err - } - return selectField(inputs, field, strings.Fields) -} - -func (s *Sandbox) cmdCut(args []string, stdin string) (string, error) { - delimiter := "\t" - field := 0 - var files []string - for index := 0; index < len(args); index++ { - switch { - case args[index] == "-d": - if index+1 >= len(args) { - return "", fmt.Errorf("-d requires a delimiter") - } - index++ - delimiter = args[index] - case strings.HasPrefix(args[index], "-d") && len(args[index]) > 2: - delimiter = strings.TrimPrefix(args[index], "-d") - case args[index] == "-f": - if index+1 >= len(args) { - return "", fmt.Errorf("-f requires a field") - } - index++ - field, _ = strconv.Atoi(args[index]) - case strings.HasPrefix(args[index], "-f"): - field, _ = strconv.Atoi(strings.TrimPrefix(args[index], "-f")) - default: - if strings.HasPrefix(args[index], "-") { - return "", fmt.Errorf("unknown option %s", args[index]) - } - files = append(files, args[index]) - } - } - if field < 1 { - return "", fmt.Errorf("select a positive field with -f") - } - if delimiter == "" { - return "", fmt.Errorf("delimiter cannot be empty") - } - inputs, err := s.readInputs(files, stdin) - if err != nil { - return "", err - } - return selectField(inputs, field, func(line string) []string { return strings.Split(line, delimiter) }) -} - -func selectField(inputs []namedText, field int, split func(string) []string) (string, error) { - var output commandOutputBuffer - for _, input := range inputs { - for _, line := range textLines(input.text) { - fields := split(line) - if field <= len(fields) { - output.WriteString(fields[field-1]) - } - output.WriteByte('\n') - } - } - return output.Result() -} - func cmdEcho(args []string) (string, error) { newline := true if len(args) > 0 && args[0] == "-n" { diff --git a/internal/sandbox/commands_tr.go b/internal/sandbox/commands_tr.go new file mode 100644 index 0000000..84da725 --- /dev/null +++ b/internal/sandbox/commands_tr.go @@ -0,0 +1,82 @@ +package sandbox + +import ( + "fmt" + "strings" +) + +func (s *Sandbox) cmdTr(args []string, stdin string) (string, error) { + options, operands, err := parseShortOptions(args, "ds", true) + if err != nil { + return "", err + } + deleteSet, squeeze := strings.ContainsRune(options, 'd'), strings.ContainsRune(options, 's') + if len(operands) == 0 || (!deleteSet && !squeeze && len(operands) < 2) || len(operands) > 2 { + return "", fmt.Errorf("usage: tr [-ds] SET1 [SET2]") + } + set1, err := expandCharacterSet(operands[0]) + if err != nil { + return "", err + } + set2 := []rune(nil) + if len(operands) == 2 { + set2, err = expandCharacterSet(operands[1]) + if err != nil { + return "", err + } + } + deleteMap := make(map[rune]bool, len(set1)) + translations := make(map[rune]rune, len(set1)) + for index, char := range set1 { + deleteMap[char] = true + if !deleteSet && len(set2) > 0 { + targetIndex := min(index, len(set2)-1) + translations[char] = set2[targetIndex] + } + } + squeezeSet := set2 + if deleteSet || len(squeezeSet) == 0 { + squeezeSet = set1 + } + squeezeMap := make(map[rune]bool, len(squeezeSet)) + for _, char := range squeezeSet { + squeezeMap[char] = true + } + var output commandOutputBuffer + var previous rune + havePrevious := false + for _, char := range stdin { + if deleteSet && deleteMap[char] { + continue + } + if translated, exists := translations[char]; exists { + char = translated + } + if squeeze && havePrevious && char == previous && squeezeMap[char] { + continue + } + output.WriteRune(char) + previous, havePrevious = char, true + } + return output.Result() +} + +func expandCharacterSet(value string) ([]rune, error) { + value = strings.NewReplacer(`\n`, "\n", `\t`, "\t", `\r`, "\r").Replace(value) + runes := []rune(value) + result := make([]rune, 0, len(runes)) + for index := 0; index < len(runes); index++ { + if index+2 < len(runes) && runes[index+1] == '-' { + if runes[index] > runes[index+2] { + return nil, fmt.Errorf("descending character range %c-%c", runes[index], runes[index+2]) + } + for char := runes[index]; char <= runes[index+2]; char++ { + result = append(result, char) + } + index += 2 + continue + } + result = append(result, runes[index]) + } + return result, nil +} diff --git a/internal/sandbox/dispatch.go b/internal/sandbox/dispatch.go new file mode 100644 index 0000000..dd19cb8 --- /dev/null +++ b/internal/sandbox/dispatch.go @@ -0,0 +1,114 @@ +package sandbox + +import ( + "fmt" +) + +func (s *Sandbox) run(context *executionContext, args []string, stdin string) (output string, err error) { + if len(args) == 0 { + return "", nil + } + context.dispatchSteps++ + if context.dispatchSteps > maxExecutionDispatchSteps { + return "", fmt.Errorf("command dispatch limit of %d exceeded", maxExecutionDispatchSteps) + } + defer func() { + if err == nil && len(output) > maxCommandOutputBytes { + output = "" + err = commandOutputLimitError() + } + }() + if len(context.scriptStack) > 0 { + context.scriptSteps++ + if context.scriptSteps > maxScriptSteps { + return "", fmt.Errorf("script command limit of %d exceeded", maxScriptSteps) + } + } + if isExecutableScriptPath(args[0]) { + context.commands = append(context.commands, "sh") + return s.cmdExecutableScript(context, args, stdin) + } + context.commands = append(context.commands, args[0]) + switch args[0] { + case "pwd": + return s.cmdPwd(args[1:]) + case "cd": + return s.cmdCD(args[1:]) + case "ls": + return s.cmdLS(args[1:]) + case "mkdir": + return s.cmdMkdir(args[1:]) + case "touch": + return s.cmdTouch(args[1:]) + case "cp": + return s.cmdCopy(args[1:]) + case "mv": + return s.cmdMove(args[1:]) + case "rm", "rmdir": + return s.cmdRemove(args[0], args[1:]) + case "cat", "less": + return s.cmdCat(args[1:], stdin) + case "head": + return s.cmdHeadTail(args[1:], stdin, true) + case "tail": + return s.cmdHeadTail(args[1:], stdin, false) + case "history": + return s.cmdHistory(args[1:]) + case "grep": + return s.cmdGrep(args[1:], stdin) + case "find": + return s.cmdFind(context, args[1:]) + case "sh": + return s.cmdSh(context, args[1:], stdin) + case "chmod": + return s.cmdChmod(args[1:]) + case "chown": + return s.cmdChown(args[1:]) + case "ps": + return s.cmdPS(args[1:]) + case "kill": + return s.cmdKill(args[1:]) + case "tar": + return s.cmdTar(args[1:]) + case "gzip", "gunzip": + return s.cmdGzip(args[0], args[1:]) + case "echo": + return cmdEcho(args[1:]) + case "printf": + return cmdPrintf(args[1:]) + case "export": + return s.cmdExport(args[1:]) + case "env": + return s.cmdEnv(args[1:]) + case "sort": + return s.cmdSort(args[1:], stdin) + case "uniq": + return s.cmdUniq(args[1:], stdin) + case "wc": + return s.cmdWC(args[1:], stdin) + case "awk": + return s.cmdAwk(args[1:], stdin) + case "cut": + return s.cmdCut(args[1:], stdin) + case "sed": + return s.cmdSed(args[1:], stdin) + case "tr": + return s.cmdTr(args[1:], stdin) + case "du": + return s.cmdDU(args[1:]) + case "stat": + return s.cmdStat(args[1:]) + case "basename", "dirname": + return cmdPathPart(args[0], args[1:]) + case "whoami": + return s.Env["USER"] + "\n", nil + case "clear": + return "", nil + case "vi": + return "", fmt.Errorf("interactive commands are not supported inside find -exec") + case "help", "man": + return shellHelp(args[1:]) + default: + return "", fmt.Errorf("command not available in this lab; type help to see supported commands") + } +} diff --git a/internal/sandbox/filesystem.go b/internal/sandbox/filesystem.go index 64eb598..ab04bb5 100644 --- a/internal/sandbox/filesystem.go +++ b/internal/sandbox/filesystem.go @@ -52,17 +52,6 @@ func (f *FileSystem) commitSnapshot(snapshot *FileSystem) { f.entries = snapshot.entries } -func cleanVirtualMutationPath(name string) (string, error) { - name = path.Clean(name) - if !strings.HasPrefix(name, "/") { - return "", fmt.Errorf("virtual path %q must be absolute", name) - } - if len(name) > maxVirtualPathBytes { - return "", fmt.Errorf("virtual path exceeds the %d-byte limit", maxVirtualPathBytes) - } - return name, nil -} - func (f *FileSystem) contentBytes() int { total := 0 for _, entry := range f.entries { @@ -124,432 +113,3 @@ func (f *FileSystem) Paths() []string { sort.Strings(paths) return paths } - -func (f *FileSystem) EnsureDir(name string, mode uint32) error { - var err error - name, err = cleanVirtualMutationPath(name) - if err != nil { - return err - } - if name == "/" { - return nil - } - if mode == 0 { - mode = 0o755 - } - - missing := make([]string, 0) - for candidate := name; ; candidate = path.Dir(candidate) { - if entry, ok := f.entries[candidate]; ok { - if entry.Kind != Directory { - return fmt.Errorf("%s: not a directory", candidate) - } - break - } - missing = append(missing, candidate) - if candidate == "/" { - return fmt.Errorf("virtual filesystem root is missing") - } - } - if err := f.checkEntryBudget(len(missing)); err != nil { - return err - } - for index := len(missing) - 1; index >= 0; index-- { - entryMode := uint32(0o755) - if index == 0 { - entryMode = mode - } - f.entries[missing[index]] = &Entry{Kind: Directory, Mode: entryMode, Owner: "operator"} - } - return nil -} - -func (f *FileSystem) Mkdir(name string, parents bool, mode uint32) error { - var err error - name, err = cleanVirtualMutationPath(name) - if err != nil { - return err - } - if name == "/" { - if parents { - return nil - } - return fmt.Errorf("%s: file exists", name) - } - if _, ok := f.entries[name]; ok { - if parents && f.IsDir(name) { - return nil - } - return fmt.Errorf("%s: file exists", name) - } - if parents { - return f.EnsureDir(name, mode) - } - parent := path.Dir(name) - if !f.IsDir(parent) { - return fmt.Errorf("%s: no such directory", parent) - } - if err := f.checkEntryBudget(1); err != nil { - return err - } - if mode == 0 { - mode = 0o755 - } - f.entries[name] = &Entry{Kind: Directory, Mode: mode, Owner: "operator"} - return nil -} - -func (f *FileSystem) WriteFile(name, content string, mode uint32) error { - var err error - name, err = cleanVirtualMutationPath(name) - if err != nil { - return err - } - if name == "/" { - return fmt.Errorf("%s: is a directory", name) - } - if !f.IsDir(path.Dir(name)) { - return fmt.Errorf("%s: no such directory", path.Dir(name)) - } - existing, exists := f.entries[name] - if exists && existing.Kind == Directory { - return fmt.Errorf("%s: is a directory", name) - } - oldBytes := 0 - if exists { - oldBytes = len(existing.Content) - } else if err := f.checkEntryBudget(1); err != nil { - return err - } - if err := f.checkContentBudget(name, oldBytes, len(content)); err != nil { - return err - } - if mode == 0 { - if exists { - mode = existing.Mode - } else { - mode = 0o644 - } - } - owner := "operator" - if exists && existing.Owner != "" { - owner = existing.Owner - } - f.entries[name] = &Entry{Kind: Regular, Content: content, Mode: mode, Owner: owner} - return nil -} - -func (f *FileSystem) AppendFile(name, content string) error { - var err error - name, err = cleanVirtualMutationPath(name) - if err != nil { - return err - } - if existing, ok := f.entries[name]; ok { - if existing.Kind != Regular { - return fmt.Errorf("%s: is a directory", name) - } - newBytes := len(existing.Content) + len(content) - if err := f.checkContentBudget(name, len(existing.Content), newBytes); err != nil { - return err - } - existing.Content += content - return nil - } - return f.WriteFile(name, content, 0o644) -} - -func (f *FileSystem) ReadFile(name string) (string, error) { - name = path.Clean(name) - entry, ok := f.entries[name] - if !ok { - return "", fmt.Errorf("%s: no such file", name) - } - if entry.Kind != Regular { - return "", fmt.Errorf("%s: is a directory", name) - } - return entry.Content, nil -} - -func (f *FileSystem) Children(name string) ([]string, error) { - name = path.Clean(name) - if !f.IsDir(name) { - if f.Exists(name) { - return nil, fmt.Errorf("%s: not a directory", name) - } - return nil, fmt.Errorf("%s: no such directory", name) - } - children := make([]string, 0) - for candidate := range f.entries { - if candidate != name && path.Dir(candidate) == name { - children = append(children, candidate) - } - } - sort.Strings(children) - return children, nil -} - -func (f *FileSystem) Descendants(name string, includeRoot bool) ([]string, error) { - name = path.Clean(name) - if !f.Exists(name) { - return nil, fmt.Errorf("%s: no such file or directory", name) - } - items := make([]string, 0) - for candidate := range f.entries { - if candidate == name { - if includeRoot { - items = append(items, candidate) - } - continue - } - if name == "/" || strings.HasPrefix(candidate, name+"/") { - items = append(items, candidate) - } - } - sort.Strings(items) - return items, nil -} - -func (f *FileSystem) Remove(name string, recursive, force bool) error { - name = path.Clean(name) - if name == "/" { - return fmt.Errorf("refusing to remove /") - } - entry, ok := f.entries[name] - if !ok { - if force { - return nil - } - return fmt.Errorf("%s: no such file or directory", name) - } - if entry.Kind == Directory { - children, _ := f.Children(name) - if len(children) > 0 && !recursive { - return fmt.Errorf("%s: directory not empty", name) - } - if recursive { - items, _ := f.Descendants(name, true) - for i := len(items) - 1; i >= 0; i-- { - delete(f.entries, items[i]) - } - return nil - } - } - delete(f.entries, name) - return nil -} - -func (f *FileSystem) Copy(source, destination string, recursive bool) error { - source, destination, err := cleanVirtualMutationPaths(source, destination) - if err != nil { - return err - } - if source == "/" { - return fmt.Errorf("refusing to copy /") - } - src, ok := f.entries[source] - if !ok { - return fmt.Errorf("%s: no such file or directory", source) - } - if f.IsDir(destination) { - destination = path.Join(destination, path.Base(source)) - } - if source == destination { - return fmt.Errorf("%s and %s are the same path", source, destination) - } - if src.Kind == Directory && !recursive { - return fmt.Errorf("%s: is a directory (use -r)", source) - } - if !f.IsDir(path.Dir(destination)) { - return fmt.Errorf("%s: no such directory", path.Dir(destination)) - } - if src.Kind == Directory && strings.HasPrefix(destination+"/", source+"/") { - return fmt.Errorf("cannot copy a directory into itself") - } - items := []string{source} - if src.Kind == Directory { - items, err = f.Descendants(source, true) - if err != nil { - return err - } - } - type copyItem struct { - name string - entry Entry - } - plan := make([]copyItem, 0, len(items)) - additionalEntries := 0 - projectedContent := f.contentBytes() - for _, oldName := range items { - rel := strings.TrimPrefix(oldName, source) - newName := destination + rel - if _, err := cleanVirtualMutationPath(newName); err != nil { - return err - } - sourceEntry := f.entries[oldName] - if existing, exists := f.entries[newName]; exists { - if existing.Kind != sourceEntry.Kind { - return fmt.Errorf("cannot overwrite %s with %s at %s", existing.Kind, sourceEntry.Kind, newName) - } - if existing.Kind == Regular { - projectedContent -= len(existing.Content) - } - } else { - additionalEntries++ - } - clone := *sourceEntry - if clone.Kind == Regular { - if len(clone.Content) > maxVirtualFileBytes { - return fmt.Errorf("%s: file exceeds the %d KiB virtual file limit", newName, maxVirtualFileBytes/1024) - } - if len(clone.Content) > maxVirtualFileSystemBytes-projectedContent { - return fmt.Errorf("virtual filesystem content limit of %d MiB exceeded", maxVirtualFileSystemBytes/(1024*1024)) - } - projectedContent += len(clone.Content) - } - plan = append(plan, copyItem{name: newName, entry: clone}) - } - if err := f.checkEntryBudget(additionalEntries); err != nil { - return err - } - for _, item := range plan { - clone := item.entry - f.entries[item.name] = &clone - } - return nil -} - -func (f *FileSystem) Move(source, destination string) error { - source, destination, err := cleanVirtualMutationPaths(source, destination) - if err != nil { - return err - } - if !f.Exists(source) { - return fmt.Errorf("%s: no such file or directory", source) - } - if f.IsDir(destination) { - destination = path.Join(destination, path.Base(source)) - } - if source == destination { - return fmt.Errorf("%s and %s are the same path", source, destination) - } - if !f.IsDir(path.Dir(destination)) { - return fmt.Errorf("%s: no such directory", path.Dir(destination)) - } - if source == "/" || strings.HasPrefix(destination+"/", source+"/") { - return fmt.Errorf("cannot move %s to %s", source, destination) - } - items, err := f.Descendants(source, true) - if err != nil { - return err - } - for _, oldName := range items { - newName := destination + strings.TrimPrefix(oldName, source) - if _, err := cleanVirtualMutationPath(newName); err != nil { - return err - } - } - if f.Exists(destination) { - sourceEntry, _ := f.Entry(source) - destinationEntry, _ := f.Entry(destination) - if sourceEntry.Kind != destinationEntry.Kind || destinationEntry.Kind == Directory { - return fmt.Errorf("cannot replace %s with %s", destinationEntry.Kind, sourceEntry.Kind) - } - delete(f.entries, destination) - } - for _, oldName := range items { - rel := strings.TrimPrefix(oldName, source) - f.entries[destination+rel] = f.entries[oldName] - } - for i := len(items) - 1; i >= 0; i-- { - delete(f.entries, items[i]) - } - return nil -} - -func (f *FileSystem) Chmod(name string, mode uint32) error { - entry, err := f.mutableEntry(name) - if err != nil { - return err - } - entry.Mode = mode - return nil -} - -func (f *FileSystem) Chown(name, owner string) error { - if len(owner) > maxVirtualOwnerBytes { - return fmt.Errorf("owner exceeds the %d-byte limit", maxVirtualOwnerBytes) - } - entry, err := f.mutableEntry(name) - if err != nil { - return err - } - entry.Owner = owner - return nil -} - -func (f *FileSystem) mutableEntry(name string) (*Entry, error) { - entry, ok := f.entries[path.Clean(name)] - if !ok { - return nil, fmt.Errorf("%s: no such file or directory", name) - } - return entry, nil -} - -func cleanVirtualMutationPaths(source, destination string) (string, string, error) { - cleanSource, err := cleanVirtualMutationPath(source) - if err != nil { - return "", "", err - } - cleanDestination, err := cleanVirtualMutationPath(destination) - return cleanSource, cleanDestination, err -} - -func (f *FileSystem) Glob(cwd, pattern string) []string { - absPattern := Clean(cwd, pattern) - matches := make([]string, 0) - for candidate := range f.entries { - matched, err := path.Match(absPattern, candidate) - if err == nil && matched { - if strings.HasPrefix(pattern, "/") { - matches = append(matches, candidate) - } else { - relative := relativePath(cwd, candidate) - if strings.HasPrefix(pattern, "./") && !strings.HasPrefix(relative, ".") { - relative = "./" + relative - } - matches = append(matches, relative) - } - } - } - sort.Strings(matches) - return matches -} - -func relativePath(base, target string) string { - base, target = path.Clean(base), path.Clean(target) - if target == base { - return "." - } - baseParts := strings.Split(strings.TrimPrefix(base, "/"), "/") - targetParts := strings.Split(strings.TrimPrefix(target, "/"), "/") - if base == "/" { - baseParts = nil - } - if target == "/" { - targetParts = nil - } - common := 0 - for common < len(baseParts) && common < len(targetParts) && baseParts[common] == targetParts[common] { - common++ - } - parts := make([]string, 0, len(baseParts)-common+len(targetParts)-common) - for range baseParts[common:] { - parts = append(parts, "..") - } - parts = append(parts, targetParts[common:]...) - if len(parts) == 0 { - return "." - } - return strings.Join(parts, "/") -} diff --git a/internal/sandbox/filesystem_mutations.go b/internal/sandbox/filesystem_mutations.go new file mode 100644 index 0000000..3e2fe08 --- /dev/null +++ b/internal/sandbox/filesystem_mutations.go @@ -0,0 +1,347 @@ +package sandbox + +import ( + "fmt" + "path" + "strings" +) + +func cleanVirtualMutationPath(name string) (string, error) { + name = path.Clean(name) + if !strings.HasPrefix(name, "/") { + return "", fmt.Errorf("virtual path %q must be absolute", name) + } + if len(name) > maxVirtualPathBytes { + return "", fmt.Errorf("virtual path exceeds the %d-byte limit", maxVirtualPathBytes) + } + return name, nil +} + +func (f *FileSystem) EnsureDir(name string, mode uint32) error { + var err error + name, err = cleanVirtualMutationPath(name) + if err != nil { + return err + } + if name == "/" { + return nil + } + if mode == 0 { + mode = 0o755 + } + + missing := make([]string, 0) + for candidate := name; ; candidate = path.Dir(candidate) { + if entry, ok := f.entries[candidate]; ok { + if entry.Kind != Directory { + return fmt.Errorf("%s: not a directory", candidate) + } + break + } + missing = append(missing, candidate) + if candidate == "/" { + return fmt.Errorf("virtual filesystem root is missing") + } + } + if err := f.checkEntryBudget(len(missing)); err != nil { + return err + } + for index := len(missing) - 1; index >= 0; index-- { + entryMode := uint32(0o755) + if index == 0 { + entryMode = mode + } + f.entries[missing[index]] = &Entry{Kind: Directory, Mode: entryMode, Owner: "operator"} + } + return nil +} + +func (f *FileSystem) Mkdir(name string, parents bool, mode uint32) error { + var err error + name, err = cleanVirtualMutationPath(name) + if err != nil { + return err + } + if name == "/" { + if parents { + return nil + } + return fmt.Errorf("%s: file exists", name) + } + if _, ok := f.entries[name]; ok { + if parents && f.IsDir(name) { + return nil + } + return fmt.Errorf("%s: file exists", name) + } + if parents { + return f.EnsureDir(name, mode) + } + parent := path.Dir(name) + if !f.IsDir(parent) { + return fmt.Errorf("%s: no such directory", parent) + } + if err := f.checkEntryBudget(1); err != nil { + return err + } + if mode == 0 { + mode = 0o755 + } + f.entries[name] = &Entry{Kind: Directory, Mode: mode, Owner: "operator"} + return nil +} + +func (f *FileSystem) WriteFile(name, content string, mode uint32) error { + var err error + name, err = cleanVirtualMutationPath(name) + if err != nil { + return err + } + if name == "/" { + return fmt.Errorf("%s: is a directory", name) + } + if !f.IsDir(path.Dir(name)) { + return fmt.Errorf("%s: no such directory", path.Dir(name)) + } + existing, exists := f.entries[name] + if exists && existing.Kind == Directory { + return fmt.Errorf("%s: is a directory", name) + } + oldBytes := 0 + if exists { + oldBytes = len(existing.Content) + } else if err := f.checkEntryBudget(1); err != nil { + return err + } + if err := f.checkContentBudget(name, oldBytes, len(content)); err != nil { + return err + } + if mode == 0 { + if exists { + mode = existing.Mode + } else { + mode = 0o644 + } + } + owner := "operator" + if exists && existing.Owner != "" { + owner = existing.Owner + } + f.entries[name] = &Entry{Kind: Regular, Content: content, Mode: mode, Owner: owner} + return nil +} + +func (f *FileSystem) AppendFile(name, content string) error { + var err error + name, err = cleanVirtualMutationPath(name) + if err != nil { + return err + } + if existing, ok := f.entries[name]; ok { + if existing.Kind != Regular { + return fmt.Errorf("%s: is a directory", name) + } + newBytes := len(existing.Content) + len(content) + if err := f.checkContentBudget(name, len(existing.Content), newBytes); err != nil { + return err + } + existing.Content += content + return nil + } + return f.WriteFile(name, content, 0o644) +} + +func (f *FileSystem) Remove(name string, recursive, force bool) error { + name = path.Clean(name) + if name == "/" { + return fmt.Errorf("refusing to remove /") + } + entry, ok := f.entries[name] + if !ok { + if force { + return nil + } + return fmt.Errorf("%s: no such file or directory", name) + } + if entry.Kind == Directory { + children, _ := f.Children(name) + if len(children) > 0 && !recursive { + return fmt.Errorf("%s: directory not empty", name) + } + if recursive { + items, _ := f.Descendants(name, true) + for i := len(items) - 1; i >= 0; i-- { + delete(f.entries, items[i]) + } + return nil + } + } + delete(f.entries, name) + return nil +} + +func (f *FileSystem) Copy(source, destination string, recursive bool) error { + source, destination, err := cleanVirtualMutationPaths(source, destination) + if err != nil { + return err + } + if source == "/" { + return fmt.Errorf("refusing to copy /") + } + src, ok := f.entries[source] + if !ok { + return fmt.Errorf("%s: no such file or directory", source) + } + if f.IsDir(destination) { + destination = path.Join(destination, path.Base(source)) + } + if source == destination { + return fmt.Errorf("%s and %s are the same path", source, destination) + } + if src.Kind == Directory && !recursive { + return fmt.Errorf("%s: is a directory (use -r)", source) + } + if !f.IsDir(path.Dir(destination)) { + return fmt.Errorf("%s: no such directory", path.Dir(destination)) + } + if src.Kind == Directory && strings.HasPrefix(destination+"/", source+"/") { + return fmt.Errorf("cannot copy a directory into itself") + } + items := []string{source} + if src.Kind == Directory { + items, err = f.Descendants(source, true) + if err != nil { + return err + } + } + type copyItem struct { + name string + entry Entry + } + plan := make([]copyItem, 0, len(items)) + additionalEntries := 0 + projectedContent := f.contentBytes() + for _, oldName := range items { + rel := strings.TrimPrefix(oldName, source) + newName := destination + rel + if _, err := cleanVirtualMutationPath(newName); err != nil { + return err + } + sourceEntry := f.entries[oldName] + if existing, exists := f.entries[newName]; exists { + if existing.Kind != sourceEntry.Kind { + return fmt.Errorf("cannot overwrite %s with %s at %s", existing.Kind, sourceEntry.Kind, newName) + } + if existing.Kind == Regular { + projectedContent -= len(existing.Content) + } + } else { + additionalEntries++ + } + clone := *sourceEntry + if clone.Kind == Regular { + if len(clone.Content) > maxVirtualFileBytes { + return fmt.Errorf("%s: file exceeds the %d KiB virtual file limit", newName, maxVirtualFileBytes/1024) + } + if len(clone.Content) > maxVirtualFileSystemBytes-projectedContent { + return fmt.Errorf("virtual filesystem content limit of %d MiB exceeded", maxVirtualFileSystemBytes/(1024*1024)) + } + projectedContent += len(clone.Content) + } + plan = append(plan, copyItem{name: newName, entry: clone}) + } + if err := f.checkEntryBudget(additionalEntries); err != nil { + return err + } + for _, item := range plan { + clone := item.entry + f.entries[item.name] = &clone + } + return nil +} + +func (f *FileSystem) Move(source, destination string) error { + source, destination, err := cleanVirtualMutationPaths(source, destination) + if err != nil { + return err + } + if !f.Exists(source) { + return fmt.Errorf("%s: no such file or directory", source) + } + if f.IsDir(destination) { + destination = path.Join(destination, path.Base(source)) + } + if source == destination { + return fmt.Errorf("%s and %s are the same path", source, destination) + } + if !f.IsDir(path.Dir(destination)) { + return fmt.Errorf("%s: no such directory", path.Dir(destination)) + } + if source == "/" || strings.HasPrefix(destination+"/", source+"/") { + return fmt.Errorf("cannot move %s to %s", source, destination) + } + items, err := f.Descendants(source, true) + if err != nil { + return err + } + for _, oldName := range items { + newName := destination + strings.TrimPrefix(oldName, source) + if _, err := cleanVirtualMutationPath(newName); err != nil { + return err + } + } + if f.Exists(destination) { + sourceEntry, _ := f.Entry(source) + destinationEntry, _ := f.Entry(destination) + if sourceEntry.Kind != destinationEntry.Kind || destinationEntry.Kind == Directory { + return fmt.Errorf("cannot replace %s with %s", destinationEntry.Kind, sourceEntry.Kind) + } + delete(f.entries, destination) + } + for _, oldName := range items { + rel := strings.TrimPrefix(oldName, source) + f.entries[destination+rel] = f.entries[oldName] + } + for i := len(items) - 1; i >= 0; i-- { + delete(f.entries, items[i]) + } + return nil +} + +func (f *FileSystem) Chmod(name string, mode uint32) error { + entry, err := f.mutableEntry(name) + if err != nil { + return err + } + entry.Mode = mode + return nil +} + +func (f *FileSystem) Chown(name, owner string) error { + if len(owner) > maxVirtualOwnerBytes { + return fmt.Errorf("owner exceeds the %d-byte limit", maxVirtualOwnerBytes) + } + entry, err := f.mutableEntry(name) + if err != nil { + return err + } + entry.Owner = owner + return nil +} + +func (f *FileSystem) mutableEntry(name string) (*Entry, error) { + entry, ok := f.entries[path.Clean(name)] + if !ok { + return nil, fmt.Errorf("%s: no such file or directory", name) + } + return entry, nil +} + +func cleanVirtualMutationPaths(source, destination string) (string, string, error) { + cleanSource, err := cleanVirtualMutationPath(source) + if err != nil { + return "", "", err + } + cleanDestination, err := cleanVirtualMutationPath(destination) + return cleanSource, cleanDestination, err +} diff --git a/internal/sandbox/filesystem_queries.go b/internal/sandbox/filesystem_queries.go new file mode 100644 index 0000000..91f2b17 --- /dev/null +++ b/internal/sandbox/filesystem_queries.go @@ -0,0 +1,108 @@ +package sandbox + +import ( + "fmt" + "path" + "sort" + "strings" +) + +func (f *FileSystem) ReadFile(name string) (string, error) { + name = path.Clean(name) + entry, ok := f.entries[name] + if !ok { + return "", fmt.Errorf("%s: no such file", name) + } + if entry.Kind != Regular { + return "", fmt.Errorf("%s: is a directory", name) + } + return entry.Content, nil +} + +func (f *FileSystem) Children(name string) ([]string, error) { + name = path.Clean(name) + if !f.IsDir(name) { + if f.Exists(name) { + return nil, fmt.Errorf("%s: not a directory", name) + } + return nil, fmt.Errorf("%s: no such directory", name) + } + children := make([]string, 0) + for candidate := range f.entries { + if candidate != name && path.Dir(candidate) == name { + children = append(children, candidate) + } + } + sort.Strings(children) + return children, nil +} + +func (f *FileSystem) Descendants(name string, includeRoot bool) ([]string, error) { + name = path.Clean(name) + if !f.Exists(name) { + return nil, fmt.Errorf("%s: no such file or directory", name) + } + items := make([]string, 0) + for candidate := range f.entries { + if candidate == name { + if includeRoot { + items = append(items, candidate) + } + continue + } + if name == "/" || strings.HasPrefix(candidate, name+"/") { + items = append(items, candidate) + } + } + sort.Strings(items) + return items, nil +} + +func (f *FileSystem) Glob(cwd, pattern string) []string { + absPattern := Clean(cwd, pattern) + matches := make([]string, 0) + for candidate := range f.entries { + matched, err := path.Match(absPattern, candidate) + if err == nil && matched { + if strings.HasPrefix(pattern, "/") { + matches = append(matches, candidate) + } else { + relative := relativePath(cwd, candidate) + if strings.HasPrefix(pattern, "./") && !strings.HasPrefix(relative, ".") { + relative = "./" + relative + } + matches = append(matches, relative) + } + } + } + sort.Strings(matches) + return matches +} + +func relativePath(base, target string) string { + base, target = path.Clean(base), path.Clean(target) + if target == base { + return "." + } + baseParts := strings.Split(strings.TrimPrefix(base, "/"), "/") + targetParts := strings.Split(strings.TrimPrefix(target, "/"), "/") + if base == "/" { + baseParts = nil + } + if target == "/" { + targetParts = nil + } + common := 0 + for common < len(baseParts) && common < len(targetParts) && baseParts[common] == targetParts[common] { + common++ + } + parts := make([]string, 0, len(baseParts)-common+len(targetParts)-common) + for range baseParts[common:] { + parts = append(parts, "..") + } + parts = append(parts, targetParts[common:]...) + if len(parts) == 0 { + return "." + } + return strings.Join(parts, "/") +} diff --git a/internal/sandbox/lexer.go b/internal/sandbox/lexer.go new file mode 100644 index 0000000..2f2f76a --- /dev/null +++ b/internal/sandbox/lexer.go @@ -0,0 +1,175 @@ +package sandbox + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +type tokenKind int + +type token struct { + kind tokenKind + value string + glob bool +} + +func lex(line string, env map[string]string) ([]token, error) { + var tokens []token + var current strings.Builder + var quote rune + tokenStarted := false + wordGlob := false + runes := []rune(line) + expandedBytes := 0 + + reserve := func(size int) error { + if size > maxExpandedTokenBytes-expandedBytes { + return fmt.Errorf("expanded command exceeds the %d KiB token limit", maxExpandedTokenBytes/1024) + } + expandedBytes += size + return nil + } + writeString := func(value string) error { + if err := reserve(len(value)); err != nil { + return err + } + current.WriteString(value) + return nil + } + writeRune := func(value rune) error { + size := utf8.RuneLen(value) + if size < 0 { + size = utf8.RuneLen(utf8.RuneError) + } + if err := reserve(size); err != nil { + return err + } + current.WriteRune(value) + return nil + } + + flush := func() { + if tokenStarted { + tokens = append(tokens, token{kind: wordToken, value: current.String(), glob: wordGlob}) + current.Reset() + tokenStarted = false + wordGlob = false + } + } + + for i := 0; i < len(runes); i++ { + char := runes[i] + if quote == 0 { + switch { + case unicode.IsSpace(char): + flush() + continue + case char == '\'' || char == '"': + quote = char + tokenStarted = true + continue + case char == '\\': + if i+1 >= len(runes) { + return nil, fmt.Errorf("unfinished escape") + } + i++ + if err := writeRune(runes[i]); err != nil { + return nil, err + } + tokenStarted = true + continue + case char == '#' && !tokenStarted: + flush() + return tokens, nil + case char == '|': + flush() + tokens = append(tokens, token{kind: pipeToken, value: "|"}) + continue + case char == '<': + flush() + tokens = append(tokens, token{kind: inputToken, value: "<"}) + continue + case char == '>': + flush() + if i+1 < len(runes) && runes[i+1] == '>' { + i++ + tokens = append(tokens, token{kind: appendToken, value: ">>"}) + } else { + tokens = append(tokens, token{kind: redirectToken, value: ">"}) + } + continue + case char == '$': + value, consumed := expandVariable(runes[i:], env) + if err := writeString(value); err != nil { + return nil, err + } + wordGlob = wordGlob || strings.ContainsAny(value, "*?[") + i += consumed + tokenStarted = true + continue + default: + if err := writeRune(char); err != nil { + return nil, err + } + wordGlob = wordGlob || strings.ContainsRune("*?[", char) + tokenStarted = true + continue + } + } + + if char == quote { + quote = 0 + continue + } + if quote == '"' && char == '\\' { + if i+1 >= len(runes) { + return nil, fmt.Errorf("unfinished escape") + } + i++ + if err := writeRune(runes[i]); err != nil { + return nil, err + } + continue + } + if quote == '"' && char == '$' { + value, consumed := expandVariable(runes[i:], env) + if err := writeString(value); err != nil { + return nil, err + } + i += consumed + continue + } + if err := writeRune(char); err != nil { + return nil, err + } + } + if quote != 0 { + return nil, fmt.Errorf("unterminated quote") + } + flush() + return tokens, nil +} + +func expandVariable(input []rune, env map[string]string) (string, int) { + if len(input) < 2 { + return "$", 0 + } + if input[1] == '{' { + for i := 2; i < len(input); i++ { + if input[i] == '}' { + return env[string(input[2:i])], i + } + } + return "$", 0 + } + i := 1 + for i < len(input) && (unicode.IsLetter(input[i]) || unicode.IsDigit(input[i]) || input[i] == '_') { + i++ + } + if i == 1 { + return "$", 0 + } + return env[string(input[1:i])], i - 1 +} diff --git a/internal/sandbox/manuals.go b/internal/sandbox/manuals.go new file mode 100644 index 0000000..aca7236 --- /dev/null +++ b/internal/sandbox/manuals.go @@ -0,0 +1,89 @@ +package sandbox + +import ( + "fmt" + "strings" +) + +func shellHelp(args []string) (string, error) { + if len(args) > 1 { + return "", fmt.Errorf("usage: help [COMMAND]") + } + if len(args) == 1 { + manual, exists := commandManuals[args[0]] + if !exists { + return "", fmt.Errorf("no help available for %s", args[0]) + } + return manual + "\n", nil + } + commands := CommandNames() + return "Available lab commands:\n " + strings.Join(commands, " ") + + "\n\nShell features: pipelines (|), input (<), output (>), and append (>>) redirection." + + fmt.Sprintf("\nSandbox limits: %d KiB per command line; %d KiB expanded tokens; %d expanded arguments; %d pipeline stages; %d command dispatches; %d MiB per file and command output; %d MiB filesystem content and %d MiB archive payload; %d filesystem entries and %d archive entries.", + maxCommandLineBytes/1024, maxExpandedTokenBytes/1024, maxExpandedArguments, maxPipelineStages, maxExecutionDispatchSteps, + maxVirtualFileBytes/(1024*1024), maxVirtualFileSystemBytes/(1024*1024), maxVirtualArchiveBytes/(1024*1024), maxVirtualEntries, maxVirtualArchiveEntries) + + "\nUse help COMMAND for examples.\n", nil +} + +// CommandNames returns the commands accepted by the teaching-shell dispatcher. +// Interactive completion uses the same list as shell help so the two surfaces +// cannot drift apart. +func CommandNames() []string { + return sortedKeys(commandManuals) +} + +var commandManuals = map[string]string{ + "awk": "awk '{print $N}' [FILE] — print a whitespace-separated field", + "basename": "basename PATH [SUFFIX] — print the final path component", + "cat": "cat [FILE...] — concatenate files or pipeline input", + "cd": "cd [DIR] — change directory; cd - returns to the previous directory", + "chmod": "chmod MODE FILE... — change octal permissions, for example chmod 750 deploy.sh", + "chown": "chown OWNER FILE... — change a file owner; OWNER is limited to 256 bytes", + "clear": "clear — clear output in a real terminal; it is a no-op in scripted labs", + "cp": "cp [-r] SOURCE... DEST — copy files or directory trees", + "cut": "cut -d DELIMITER -f FIELD [FILE] — select a delimited field", + "dirname": "dirname PATH — print a path without its final component", + "du": "du [-a|-s] [-b|-h] [PATH...] — show virtual file sizes", + "echo": "echo [-n] [TEXT...] — print arguments", + "env": "env — print the current environment", + "export": "export NAME=value... — set shell environment variables", + "find": "find [PATH] [-name GLOB] [-type f|d] [-exec COMMAND {} \\;]", + "grep": "grep [-rilnvcFwE] PATTERN [FILE...] — print lines matching a pattern", + "gzip": "gzip FILE... — add the .gz suffix to virtual compressed files", + "gunzip": "gunzip FILE.gz... — restore virtual compressed files", + "head": "head [-n COUNT] [FILE...] — print the first lines", + "help": "help [COMMAND] — list lab commands or show focused command help", + "history": "history — show commands entered in this mission attempt", + "kill": "kill [-9|-15] PID... — stop a mission process", + "less": "less FILE... — display file content in the non-interactive lab", + "ls": "ls [-la] [PATH...] — list directory contents", + "man": "man COMMAND — show the same focused help as help COMMAND", + "mkdir": "mkdir [-p] DIR... — create directories", + "mv": "mv SOURCE... DEST — move or rename paths", + "printf": "printf FORMAT [VALUE...] — print formatted text with %s and escapes", + "ps": "ps — list the mission's running processes", + "pwd": "pwd — print the current working directory", + "rm": "rm [-rf] PATH... — remove paths inside the virtual filesystem", + "rmdir": "rmdir DIR... — remove empty directories", + "sed": "sed [-i] 's/REGEX/REPLACEMENT/g' [FILE] — transform text", + "sh": "sh FILE — run a virtual UTF-8 script through the OpsQuest teaching shell\n" + + "Blank lines, comments, #!/bin/sh, existing commands, variables, pipelines, and redirection are supported.\n" + + "Executable paths such as ./deploy.sh require a shebang and an executable mode; sh FILE does not.\n" + + "Scripts stop at the first error, restore their working directory and environment, and report virtual file/line locations.\n" + + "Limits: 64 KiB per script, 8 KiB per line, nesting depth 8, 256 dispatched commands, and 1 MiB output.\n" + + "Options, arguments, stdin, loops, conditionals, functions, substitutions, background jobs, and external programs are unsupported.", + "sort": "sort [-nru] [FILE...] — sort lines", + "stat": "stat PATH... — inspect type, size, owner, and mode", + "tail": "tail [-n COUNT] [FILE...] — print the last lines", + "tar": "tar -xf ARCHIVE [-C DIR] — extract; -C is extraction-only; -cf creates and -tf lists", + "touch": "touch FILE... — create empty files when they do not exist", + "tr": "tr [-ds] SET1 [SET2] — translate, delete, or squeeze characters", + "uniq": "uniq [-c] [FILE] — collapse adjacent duplicate lines", + "vi": "vi FILE — edit one virtual UTF-8 text file up to 256 KiB interactively\n" + + "Normal mode: h/j/k/l or arrows move, i inserts, x deletes a character, and dd deletes a line.\n" + + "Insert mode: type text, Enter adds a line, Backspace deletes, and Esc returns to Normal mode.\n" + + "Commands: :w writes, :q quits an unchanged buffer, :wq writes and quits, and :q! discards changes.\n" + + "Options, multiple files, pipelines, redirection, shell escapes, plugins, and other vi features are unsupported.", + "wc": "wc [-l|-w|-c] [FILE...] — count lines, words, or bytes", + "whoami": "whoami — print the current virtual user", +} diff --git a/internal/sandbox/options.go b/internal/sandbox/options.go new file mode 100644 index 0000000..f6f4692 --- /dev/null +++ b/internal/sandbox/options.go @@ -0,0 +1,36 @@ +package sandbox + +import ( + "fmt" + "sort" + "strings" +) + +func sortedKeys[V any](items map[string]V) []string { + keys := make([]string, 0, len(items)) + for key := range items { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// parseShortOptions handles the shared, deliberately small combined-short-flag +// syntax used by teaching commands such as ls, du, sort, and tr. +func parseShortOptions(args []string, valid string, dashIsOperand bool) (string, []string, error) { + var options strings.Builder + operands := make([]string, 0, len(args)) + for _, arg := range args { + if !strings.HasPrefix(arg, "-") || dashIsOperand && arg == "-" { + operands = append(operands, arg) + continue + } + for _, option := range strings.TrimPrefix(arg, "-") { + if !strings.ContainsRune(valid, option) { + return "", nil, fmt.Errorf("unknown option -%c", option) + } + options.WriteRune(option) + } + } + return options.String(), operands, nil +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 2625ea4..7d162c6 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -134,10 +134,6 @@ func (s *Sandbox) finalDestination(source, destination string) string { return destination } -func cloneArchives(source map[string]Archive) map[string]Archive { - return cloneMap(source) -} - func validateEnvironment(environment map[string]string) error { if len(environment) > maxVirtualEnvironmentEntries { return fmt.Errorf("virtual environment entry limit of %d exceeded", maxVirtualEnvironmentEntries) @@ -164,84 +160,3 @@ func cloneMap[K comparable, V any](source map[K]V) map[K]V { } return cloned } - -func validateArchiveMetadata(archives map[string]Archive) error { - entryCount := 0 - contentBytes := 0 - for archivePath, archive := range archives { - for _, entry := range archive.Entries { - if entryCount == maxVirtualArchiveEntries { - return fmt.Errorf("virtual archive entry limit of %d exceeded", maxVirtualArchiveEntries) - } - entryCount++ - if len(entry.Content) > maxVirtualFileBytes { - return fmt.Errorf("%s: archive entry %q exceeds the %d KiB content limit", archivePath, entry.Path, maxVirtualFileBytes/1024) - } - if len(entry.Content) > maxVirtualArchiveBytes-contentBytes { - return fmt.Errorf("virtual archive content limit of %d MiB exceeded", maxVirtualArchiveBytes/(1024*1024)) - } - contentBytes += len(entry.Content) - } - } - return nil -} - -func (s *Sandbox) planArchiveReplacement(archivePath string, archive Archive) (map[string]Archive, error) { - archives := cloneArchives(s.Archives) - archives[archivePath] = archive - if err := validateArchiveMetadata(archives); err != nil { - return nil, err - } - return archives, nil -} - -func (s *Sandbox) planArchiveCopy(source, destination string) (map[string]Archive, error) { - archives := cloneArchives(s.Archives) - copies := make(map[string]Archive) - for archivePath, archive := range s.Archives { - if archivePath == source || strings.HasPrefix(archivePath, source+"/") { - copies[destination+strings.TrimPrefix(archivePath, source)] = archive - } - } - paths, err := s.FS.Descendants(source, true) - if err != nil { - return nil, err - } - for _, sourcePath := range paths { - relative := strings.TrimPrefix(sourcePath, source) - delete(archives, destination+relative) - } - for archivePath, archive := range copies { - archives[archivePath] = archive - } - if err := validateArchiveMetadata(archives); err != nil { - return nil, err - } - return archives, nil -} - -func (s *Sandbox) moveArchiveMetadata(source, destination string) { - moves := make(map[string]Archive) - for archivePath, archive := range s.Archives { - if archivePath == source || strings.HasPrefix(archivePath, source+"/") { - moves[destination+strings.TrimPrefix(archivePath, source)] = archive - } - } - s.removeArchiveMetadata(destination) - s.removeArchiveMetadata(source) - for archivePath, archive := range moves { - s.Archives[archivePath] = archive - } -} - -func (s *Sandbox) removeArchiveMetadata(target string) { - removeArchiveMetadata(s.Archives, target) -} - -func removeArchiveMetadata(archives map[string]Archive, target string) { - for archivePath := range archives { - if archivePath == target || strings.HasPrefix(archivePath, target+"/") { - delete(archives, archivePath) - } - } -} diff --git a/internal/sandbox/shell.go b/internal/sandbox/shell.go index edc49c4..56b4d6b 100644 --- a/internal/sandbox/shell.go +++ b/internal/sandbox/shell.go @@ -4,8 +4,6 @@ import ( "fmt" "slices" "strings" - "unicode" - "unicode/utf8" ) const ( @@ -32,8 +30,6 @@ type executionContext struct { scriptSteps int } -type tokenKind int - const ( wordToken tokenKind = iota pipeToken @@ -42,39 +38,6 @@ const ( inputToken ) -type token struct { - kind tokenKind - value string - glob bool -} - -type shellWord struct { - value string - glob bool -} - -type pipelineStage struct { - args []shellWord - inputPath string - outputPath string - append bool -} - -type commandLine struct { - stages []pipelineStage -} - -type expandedPipelineStage struct { - args []string - inputPath string - outputPath string - append bool -} - -type expandedCommandLine struct { - stages []expandedPipelineStage -} - func (s *Sandbox) Execute(line string) (Result, error) { if len(line) > maxCommandLineBytes { return Result{}, fmt.Errorf("command line exceeds the %d KiB limit", maxCommandLineBytes/1024) @@ -182,454 +145,3 @@ func (s *Sandbox) executeLine(line string, context *executionContext, allowInter } return Result{Output: stdin}, nil } - -func parseCommandLine(tokens []token) (commandLine, error) { - if len(tokens) == 0 { - return commandLine{}, nil - } - parsed := commandLine{} - stage := pipelineStage{} - for i := 0; i < len(tokens); i++ { - current := tokens[i] - switch current.kind { - case wordToken: - stage.args = append(stage.args, shellWord{value: current.value, glob: current.glob}) - case pipeToken: - if len(stage.args) == 0 { - return commandLine{}, fmt.Errorf("unexpected pipe") - } - if len(parsed.stages) == maxPipelineStages-1 { - return commandLine{}, fmt.Errorf("pipeline stage limit of %d exceeded", maxPipelineStages) - } - parsed.stages = append(parsed.stages, stage) - stage = pipelineStage{} - case redirectToken, appendToken, inputToken: - if i+1 >= len(tokens) || tokens[i+1].kind != wordToken { - return commandLine{}, fmt.Errorf("redirection requires a file") - } - i++ - file := tokens[i].value - if current.kind == inputToken { - if stage.inputPath != "" { - return commandLine{}, fmt.Errorf("multiple input redirections") - } - stage.inputPath = file - } else { - if stage.outputPath != "" { - return commandLine{}, fmt.Errorf("multiple output redirections") - } - stage.outputPath = file - stage.append = current.kind == appendToken - } - } - } - if len(stage.args) == 0 { - if len(parsed.stages) > 0 { - return commandLine{}, fmt.Errorf("pipeline cannot end with a pipe") - } - return commandLine{}, fmt.Errorf("redirection requires a command") - } - if len(parsed.stages) == maxPipelineStages { - return commandLine{}, fmt.Errorf("pipeline stage limit of %d exceeded", maxPipelineStages) - } - parsed.stages = append(parsed.stages, stage) - return parsed, nil -} - -func (s *Sandbox) expandCommandLine(parsed commandLine) (expandedCommandLine, error) { - expanded := expandedCommandLine{stages: make([]expandedPipelineStage, 0, len(parsed.stages))} - argumentCount := 0 - tokenBytes := 0 - consumeTokenBytes := func(value string) error { - if len(value) > maxExpandedTokenBytes-tokenBytes { - return fmt.Errorf("expanded command exceeds the %d KiB token limit", maxExpandedTokenBytes/1024) - } - tokenBytes += len(value) - return nil - } - for _, stage := range parsed.stages { - args := make([]string, 0, len(stage.args)) - for _, word := range stage.args { - values := []string{word.value} - if word.glob { - if matches := s.FS.Glob(s.CWD, word.value); len(matches) > 0 { - values = matches - } - } - for _, value := range values { - if argumentCount == maxExpandedArguments { - return expandedCommandLine{}, fmt.Errorf("expanded command exceeds the %d-argument limit", maxExpandedArguments) - } - if err := consumeTokenBytes(value); err != nil { - return expandedCommandLine{}, err - } - argumentCount++ - args = append(args, value) - } - } - if err := consumeTokenBytes(stage.inputPath); err != nil { - return expandedCommandLine{}, err - } - if err := consumeTokenBytes(stage.outputPath); err != nil { - return expandedCommandLine{}, err - } - expanded.stages = append(expanded.stages, expandedPipelineStage{ - args: args, - inputPath: stage.inputPath, - outputPath: stage.outputPath, - append: stage.append, - }) - } - return expanded, nil -} - -func lex(line string, env map[string]string) ([]token, error) { - var tokens []token - var current strings.Builder - var quote rune - tokenStarted := false - wordGlob := false - runes := []rune(line) - expandedBytes := 0 - - reserve := func(size int) error { - if size > maxExpandedTokenBytes-expandedBytes { - return fmt.Errorf("expanded command exceeds the %d KiB token limit", maxExpandedTokenBytes/1024) - } - expandedBytes += size - return nil - } - writeString := func(value string) error { - if err := reserve(len(value)); err != nil { - return err - } - current.WriteString(value) - return nil - } - writeRune := func(value rune) error { - size := utf8.RuneLen(value) - if size < 0 { - size = utf8.RuneLen(utf8.RuneError) - } - if err := reserve(size); err != nil { - return err - } - current.WriteRune(value) - return nil - } - - flush := func() { - if tokenStarted { - tokens = append(tokens, token{kind: wordToken, value: current.String(), glob: wordGlob}) - current.Reset() - tokenStarted = false - wordGlob = false - } - } - - for i := 0; i < len(runes); i++ { - char := runes[i] - if quote == 0 { - switch { - case unicode.IsSpace(char): - flush() - continue - case char == '\'' || char == '"': - quote = char - tokenStarted = true - continue - case char == '\\': - if i+1 >= len(runes) { - return nil, fmt.Errorf("unfinished escape") - } - i++ - if err := writeRune(runes[i]); err != nil { - return nil, err - } - tokenStarted = true - continue - case char == '#' && !tokenStarted: - flush() - return tokens, nil - case char == '|': - flush() - tokens = append(tokens, token{kind: pipeToken, value: "|"}) - continue - case char == '<': - flush() - tokens = append(tokens, token{kind: inputToken, value: "<"}) - continue - case char == '>': - flush() - if i+1 < len(runes) && runes[i+1] == '>' { - i++ - tokens = append(tokens, token{kind: appendToken, value: ">>"}) - } else { - tokens = append(tokens, token{kind: redirectToken, value: ">"}) - } - continue - case char == '$': - value, consumed := expandVariable(runes[i:], env) - if err := writeString(value); err != nil { - return nil, err - } - wordGlob = wordGlob || strings.ContainsAny(value, "*?[") - i += consumed - tokenStarted = true - continue - default: - if err := writeRune(char); err != nil { - return nil, err - } - wordGlob = wordGlob || strings.ContainsRune("*?[", char) - tokenStarted = true - continue - } - } - - if char == quote { - quote = 0 - continue - } - if quote == '"' && char == '\\' { - if i+1 >= len(runes) { - return nil, fmt.Errorf("unfinished escape") - } - i++ - if err := writeRune(runes[i]); err != nil { - return nil, err - } - continue - } - if quote == '"' && char == '$' { - value, consumed := expandVariable(runes[i:], env) - if err := writeString(value); err != nil { - return nil, err - } - i += consumed - continue - } - if err := writeRune(char); err != nil { - return nil, err - } - } - if quote != 0 { - return nil, fmt.Errorf("unterminated quote") - } - flush() - return tokens, nil -} - -func expandVariable(input []rune, env map[string]string) (string, int) { - if len(input) < 2 { - return "$", 0 - } - if input[1] == '{' { - for i := 2; i < len(input); i++ { - if input[i] == '}' { - return env[string(input[2:i])], i - } - } - return "$", 0 - } - i := 1 - for i < len(input) && (unicode.IsLetter(input[i]) || unicode.IsDigit(input[i]) || input[i] == '_') { - i++ - } - if i == 1 { - return "$", 0 - } - return env[string(input[1:i])], i - 1 -} - -func (s *Sandbox) run(context *executionContext, args []string, stdin string) (output string, err error) { - if len(args) == 0 { - return "", nil - } - context.dispatchSteps++ - if context.dispatchSteps > maxExecutionDispatchSteps { - return "", fmt.Errorf("command dispatch limit of %d exceeded", maxExecutionDispatchSteps) - } - defer func() { - if err == nil && len(output) > maxCommandOutputBytes { - output = "" - err = commandOutputLimitError() - } - }() - if len(context.scriptStack) > 0 { - context.scriptSteps++ - if context.scriptSteps > maxScriptSteps { - return "", fmt.Errorf("script command limit of %d exceeded", maxScriptSteps) - } - } - if isExecutableScriptPath(args[0]) { - context.commands = append(context.commands, "sh") - return s.cmdExecutableScript(context, args, stdin) - } - context.commands = append(context.commands, args[0]) - switch args[0] { - case "pwd": - return s.cmdPwd(args[1:]) - case "cd": - return s.cmdCD(args[1:]) - case "ls": - return s.cmdLS(args[1:]) - case "mkdir": - return s.cmdMkdir(args[1:]) - case "touch": - return s.cmdTouch(args[1:]) - case "cp": - return s.cmdCopy(args[1:]) - case "mv": - return s.cmdMove(args[1:]) - case "rm", "rmdir": - return s.cmdRemove(args[0], args[1:]) - case "cat", "less": - return s.cmdCat(args[1:], stdin) - case "head": - return s.cmdHeadTail(args[1:], stdin, true) - case "tail": - return s.cmdHeadTail(args[1:], stdin, false) - case "history": - return s.cmdHistory(args[1:]) - case "grep": - return s.cmdGrep(args[1:], stdin) - case "find": - return s.cmdFind(context, args[1:]) - case "sh": - return s.cmdSh(context, args[1:], stdin) - case "chmod": - return s.cmdChmod(args[1:]) - case "chown": - return s.cmdChown(args[1:]) - case "ps": - return s.cmdPS(args[1:]) - case "kill": - return s.cmdKill(args[1:]) - case "tar": - return s.cmdTar(args[1:]) - case "gzip", "gunzip": - return s.cmdGzip(args[0], args[1:]) - case "echo": - return cmdEcho(args[1:]) - case "printf": - return cmdPrintf(args[1:]) - case "export": - return s.cmdExport(args[1:]) - case "env": - return s.cmdEnv(args[1:]) - case "sort": - return s.cmdSort(args[1:], stdin) - case "uniq": - return s.cmdUniq(args[1:], stdin) - case "wc": - return s.cmdWC(args[1:], stdin) - case "awk": - return s.cmdAwk(args[1:], stdin) - case "cut": - return s.cmdCut(args[1:], stdin) - case "sed": - return s.cmdSed(args[1:], stdin) - case "tr": - return s.cmdTr(args[1:], stdin) - case "du": - return s.cmdDU(args[1:]) - case "stat": - return s.cmdStat(args[1:]) - case "basename", "dirname": - return cmdPathPart(args[0], args[1:]) - case "whoami": - return s.Env["USER"] + "\n", nil - case "clear": - return "", nil - case "vi": - return "", fmt.Errorf("interactive commands are not supported inside find -exec") - case "help", "man": - return shellHelp(args[1:]) - default: - return "", fmt.Errorf("command not available in this lab; type help to see supported commands") - } -} - -func shellHelp(args []string) (string, error) { - if len(args) > 1 { - return "", fmt.Errorf("usage: help [COMMAND]") - } - if len(args) == 1 { - manual, exists := commandManuals[args[0]] - if !exists { - return "", fmt.Errorf("no help available for %s", args[0]) - } - return manual + "\n", nil - } - commands := CommandNames() - return "Available lab commands:\n " + strings.Join(commands, " ") + - "\n\nShell features: pipelines (|), input (<), output (>), and append (>>) redirection." + - fmt.Sprintf("\nSandbox limits: %d KiB per command line; %d KiB expanded tokens; %d expanded arguments; %d pipeline stages; %d command dispatches; %d MiB per file and command output; %d MiB filesystem content and %d MiB archive payload; %d filesystem entries and %d archive entries.", - maxCommandLineBytes/1024, maxExpandedTokenBytes/1024, maxExpandedArguments, maxPipelineStages, maxExecutionDispatchSteps, - maxVirtualFileBytes/(1024*1024), maxVirtualFileSystemBytes/(1024*1024), maxVirtualArchiveBytes/(1024*1024), maxVirtualEntries, maxVirtualArchiveEntries) + - "\nUse help COMMAND for examples.\n", nil -} - -// CommandNames returns the commands accepted by the teaching-shell dispatcher. -// Interactive completion uses the same list as shell help so the two surfaces -// cannot drift apart. -func CommandNames() []string { - return sortedKeys(commandManuals) -} - -var commandManuals = map[string]string{ - "awk": "awk '{print $N}' [FILE] — print a whitespace-separated field", - "basename": "basename PATH [SUFFIX] — print the final path component", - "cat": "cat [FILE...] — concatenate files or pipeline input", - "cd": "cd [DIR] — change directory; cd - returns to the previous directory", - "chmod": "chmod MODE FILE... — change octal permissions, for example chmod 750 deploy.sh", - "chown": "chown OWNER FILE... — change a file owner; OWNER is limited to 256 bytes", - "clear": "clear — clear output in a real terminal; it is a no-op in scripted labs", - "cp": "cp [-r] SOURCE... DEST — copy files or directory trees", - "cut": "cut -d DELIMITER -f FIELD [FILE] — select a delimited field", - "dirname": "dirname PATH — print a path without its final component", - "du": "du [-a|-s] [-b|-h] [PATH...] — show virtual file sizes", - "echo": "echo [-n] [TEXT...] — print arguments", - "env": "env — print the current environment", - "export": "export NAME=value... — set shell environment variables", - "find": "find [PATH] [-name GLOB] [-type f|d] [-exec COMMAND {} \\;]", - "grep": "grep [-rilnvcFwE] PATTERN [FILE...] — print lines matching a pattern", - "gzip": "gzip FILE... — add the .gz suffix to virtual compressed files", - "gunzip": "gunzip FILE.gz... — restore virtual compressed files", - "head": "head [-n COUNT] [FILE...] — print the first lines", - "help": "help [COMMAND] — list lab commands or show focused command help", - "history": "history — show commands entered in this mission attempt", - "kill": "kill [-9|-15] PID... — stop a mission process", - "less": "less FILE... — display file content in the non-interactive lab", - "ls": "ls [-la] [PATH...] — list directory contents", - "man": "man COMMAND — show the same focused help as help COMMAND", - "mkdir": "mkdir [-p] DIR... — create directories", - "mv": "mv SOURCE... DEST — move or rename paths", - "printf": "printf FORMAT [VALUE...] — print formatted text with %s and escapes", - "ps": "ps — list the mission's running processes", - "pwd": "pwd — print the current working directory", - "rm": "rm [-rf] PATH... — remove paths inside the virtual filesystem", - "rmdir": "rmdir DIR... — remove empty directories", - "sed": "sed [-i] 's/REGEX/REPLACEMENT/g' [FILE] — transform text", - "sh": "sh FILE — run a virtual UTF-8 script through the OpsQuest teaching shell\n" + - "Blank lines, comments, #!/bin/sh, existing commands, variables, pipelines, and redirection are supported.\n" + - "Executable paths such as ./deploy.sh require a shebang and an executable mode; sh FILE does not.\n" + - "Scripts stop at the first error, restore their working directory and environment, and report virtual file/line locations.\n" + - "Limits: 64 KiB per script, 8 KiB per line, nesting depth 8, 256 dispatched commands, and 1 MiB output.\n" + - "Options, arguments, stdin, loops, conditionals, functions, substitutions, background jobs, and external programs are unsupported.", - "sort": "sort [-nru] [FILE...] — sort lines", - "stat": "stat PATH... — inspect type, size, owner, and mode", - "tail": "tail [-n COUNT] [FILE...] — print the last lines", - "tar": "tar -xf ARCHIVE [-C DIR] — extract; -C is extraction-only; -cf creates and -tf lists", - "touch": "touch FILE... — create empty files when they do not exist", - "tr": "tr [-ds] SET1 [SET2] — translate, delete, or squeeze characters", - "uniq": "uniq [-c] [FILE] — collapse adjacent duplicate lines", - "vi": "vi FILE — edit one virtual UTF-8 text file up to 256 KiB interactively\n" + - "Normal mode: h/j/k/l or arrows move, i inserts, x deletes a character, and dd deletes a line.\n" + - "Insert mode: type text, Enter adds a line, Backspace deletes, and Esc returns to Normal mode.\n" + - "Commands: :w writes, :q quits an unchanged buffer, :wq writes and quits, and :q! discards changes.\n" + - "Options, multiple files, pipelines, redirection, shell escapes, plugins, and other vi features are unsupported.", - "wc": "wc [-l|-w|-c] [FILE...] — count lines, words, or bytes", - "whoami": "whoami — print the current virtual user", -} diff --git a/internal/webapp/events.go b/internal/webapp/events.go new file mode 100644 index 0000000..d8e6a6f --- /dev/null +++ b/internal/webapp/events.go @@ -0,0 +1,102 @@ +package webapp + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/aleksandergregersen/opsquest/internal/game" +) + +type publication struct { + id uint64 + data []byte +} + +// ReportAttempt publishes the latest sanitized session snapshot without +// blocking command execution. Slow browser subscribers skip stale snapshots +// and converge on the newest one. +func (s *Server) ReportAttempt(event game.AttemptEvent) { + event = game.CloneAttemptEvent(event) + data, err := json.Marshal(event) + if err != nil { + return + } + + s.mu.Lock() + s.sequence++ + item := publication{id: s.sequence, data: data} + s.current = item + s.hasCurrent = true + for subscriber := range s.subscribers { + select { + case subscriber <- item: + default: + select { + case <-subscriber: + default: + } + select { + case subscriber <- item: + default: + } + } + } + s.mu.Unlock() +} + +func (s *Server) handleEvents(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + flusher, supported := writer.(http.Flusher) + if !supported { + http.Error(writer, "streaming unsupported", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "text/event-stream") + writer.Header().Set("Connection", "keep-alive") + writer.Header().Set("X-Accel-Buffering", "no") + + subscriber := make(chan publication, 1) + s.mu.Lock() + s.subscribers[subscriber] = struct{}{} + current, available := s.current, s.hasCurrent + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.subscribers, subscriber) + s.mu.Unlock() + }() + + if available { + writeSSE(writer, current) + } else { + fmt.Fprint(writer, "event: waiting\ndata: {}\n\n") + } + flusher.Flush() + + keepAlive := time.NewTicker(15 * time.Second) + defer keepAlive.Stop() + for { + select { + case item := <-subscriber: + writeSSE(writer, item) + flusher.Flush() + case <-keepAlive.C: + fmt.Fprint(writer, ": keep-alive\n\n") + flusher.Flush() + case <-request.Context().Done(): + return + case <-s.done: + return + } + } +} + +func writeSSE(writer http.ResponseWriter, item publication) { + fmt.Fprintf(writer, "id: %d\nevent: snapshot\ndata: %s\n\n", item.id, item.data) +} diff --git a/internal/webapp/security.go b/internal/webapp/security.go new file mode 100644 index 0000000..8923a00 --- /dev/null +++ b/internal/webapp/security.go @@ -0,0 +1,73 @@ +package webapp + +import ( + "crypto/subtle" + "net/http" +) + +func (s *Server) validateRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + s.writeSecurityHeaders(writer) + if request.Host != s.host { + http.Error(writer, "invalid companion host", http.StatusMisdirectedRequest) + return + } + if origin := request.Header.Get("Origin"); origin != "" && origin != s.baseURL { + http.Error(writer, "invalid companion origin", http.StatusForbidden) + return + } + next.ServeHTTP(writer, request) + }) +} + +func (s *Server) writeSecurityHeaders(writer http.ResponseWriter) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") + writer.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + writer.Header().Set("Permissions-Policy", "camera=(), geolocation=(), microphone=()") + writer.Header().Set("Referrer-Policy", "no-referrer") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("X-Frame-Options", "DENY") +} + +func (s *Server) handlePair(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + token := request.URL.Query().Get("token") + s.pairMu.Lock() + valid := !s.paired && constantTimeEqual(token, s.pairToken) + if valid { + s.paired = true + } + s.pairMu.Unlock() + if !valid { + http.Error(writer, "pairing link is invalid or has already been used", http.StatusUnauthorized) + return + } + http.SetCookie(writer, &http.Cookie{ + Name: companionCookie, + Value: s.sessionToken, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + http.Redirect(writer, request, "/", http.StatusSeeOther) +} + +func constantTimeEqual(actual, expected string) bool { + return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 +} + +func (s *Server) requireSession(next http.HandlerFunc) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + cookie, err := request.Cookie(companionCookie) + if err != nil || !constantTimeEqual(cookie.Value, s.sessionToken) { + http.Error(writer, "open the pairing URL printed by OpsQuest", http.StatusUnauthorized) + return + } + next(writer, request) + } +} diff --git a/internal/webapp/server.go b/internal/webapp/server.go index 57175ac..5e44ca6 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -4,10 +4,8 @@ package webapp import ( "context" "crypto/rand" - "crypto/subtle" "embed" "encoding/base64" - "encoding/json" "errors" "fmt" "net" @@ -15,8 +13,6 @@ import ( "net/url" "sync" "time" - - "github.com/aleksandergregersen/opsquest/internal/game" ) const companionCookie = "opsquest_companion" @@ -24,11 +20,6 @@ const companionCookie = "opsquest_companion" //go:embed static/* var staticFiles embed.FS -type publication struct { - id uint64 - data []byte -} - // Server is a loopback-only HTTP projection of the active game session. // Command execution and validation never enter this package. type Server struct { @@ -176,216 +167,3 @@ func (s *Server) closeUnusedConnections() { delete(s.unused, conn) } } - -// ReportAttempt publishes the latest sanitized session snapshot without -// blocking command execution. Slow browser subscribers skip stale snapshots -// and converge on the newest one. -func (s *Server) ReportAttempt(event game.AttemptEvent) { - event = game.CloneAttemptEvent(event) - data, err := json.Marshal(event) - if err != nil { - return - } - - s.mu.Lock() - s.sequence++ - item := publication{id: s.sequence, data: data} - s.current = item - s.hasCurrent = true - for subscriber := range s.subscribers { - select { - case subscriber <- item: - default: - select { - case <-subscriber: - default: - } - select { - case subscriber <- item: - default: - } - } - } - s.mu.Unlock() -} - -func (s *Server) validateRequest(next http.Handler) http.Handler { - return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - s.writeSecurityHeaders(writer) - if request.Host != s.host { - http.Error(writer, "invalid companion host", http.StatusMisdirectedRequest) - return - } - if origin := request.Header.Get("Origin"); origin != "" && origin != s.baseURL { - http.Error(writer, "invalid companion origin", http.StatusForbidden) - return - } - next.ServeHTTP(writer, request) - }) -} - -func (s *Server) writeSecurityHeaders(writer http.ResponseWriter) { - writer.Header().Set("Cache-Control", "no-store") - writer.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") - writer.Header().Set("Cross-Origin-Resource-Policy", "same-origin") - writer.Header().Set("Permissions-Policy", "camera=(), geolocation=(), microphone=()") - writer.Header().Set("Referrer-Policy", "no-referrer") - writer.Header().Set("X-Content-Type-Options", "nosniff") - writer.Header().Set("X-Frame-Options", "DENY") -} - -func (s *Server) handlePair(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - writer.Header().Set("Allow", http.MethodGet) - http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) - return - } - token := request.URL.Query().Get("token") - s.pairMu.Lock() - valid := !s.paired && constantTimeEqual(token, s.pairToken) - if valid { - s.paired = true - } - s.pairMu.Unlock() - if !valid { - http.Error(writer, "pairing link is invalid or has already been used", http.StatusUnauthorized) - return - } - http.SetCookie(writer, &http.Cookie{ - Name: companionCookie, - Value: s.sessionToken, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - }) - http.Redirect(writer, request, "/", http.StatusSeeOther) -} - -func constantTimeEqual(actual, expected string) bool { - return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 -} - -func (s *Server) requireSession(next http.HandlerFunc) http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - cookie, err := request.Cookie(companionCookie) - if err != nil || !constantTimeEqual(cookie.Value, s.sessionToken) { - http.Error(writer, "open the pairing URL printed by OpsQuest", http.StatusUnauthorized) - return - } - next(writer, request) - } -} - -func (s *Server) handleIndex(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet || request.URL.Path != "/" { - if request.Method != http.MethodGet { - writer.Header().Set("Allow", http.MethodGet) - http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) - return - } - http.NotFound(writer, request) - return - } - s.serveStatic(writer, "static/index.html", "text/html; charset=utf-8") -} - -func (s *Server) handleCSS(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - writer.Header().Set("Allow", http.MethodGet) - http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) - return - } - s.serveStatic(writer, "static/app.css", "text/css; charset=utf-8") -} - -func (s *Server) handleJavaScript(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - writer.Header().Set("Allow", http.MethodGet) - http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) - return - } - s.serveStatic(writer, "static/app.js", "text/javascript; charset=utf-8") -} - -func (s *Server) serveStatic(writer http.ResponseWriter, name, contentType string) { - content, err := staticFiles.ReadFile(name) - if err != nil { - http.Error(writer, "companion asset unavailable", http.StatusInternalServerError) - return - } - writer.Header().Set("Content-Type", contentType) - _, _ = writer.Write(content) -} - -func (s *Server) handleState(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - writer.Header().Set("Allow", http.MethodGet) - http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) - return - } - s.mu.Lock() - item, available := s.current, s.hasCurrent - s.mu.Unlock() - if !available { - writer.WriteHeader(http.StatusNoContent) - return - } - writer.Header().Set("Content-Type", "application/json") - writer.Header().Set("X-OpsQuest-Event-ID", fmt.Sprint(item.id)) - _, _ = writer.Write(item.data) -} - -func (s *Server) handleEvents(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - writer.Header().Set("Allow", http.MethodGet) - http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) - return - } - flusher, supported := writer.(http.Flusher) - if !supported { - http.Error(writer, "streaming unsupported", http.StatusInternalServerError) - return - } - writer.Header().Set("Content-Type", "text/event-stream") - writer.Header().Set("Connection", "keep-alive") - writer.Header().Set("X-Accel-Buffering", "no") - - subscriber := make(chan publication, 1) - s.mu.Lock() - s.subscribers[subscriber] = struct{}{} - current, available := s.current, s.hasCurrent - s.mu.Unlock() - defer func() { - s.mu.Lock() - delete(s.subscribers, subscriber) - s.mu.Unlock() - }() - - if available { - writeSSE(writer, current) - } else { - fmt.Fprint(writer, "event: waiting\ndata: {}\n\n") - } - flusher.Flush() - - keepAlive := time.NewTicker(15 * time.Second) - defer keepAlive.Stop() - for { - select { - case item := <-subscriber: - writeSSE(writer, item) - flusher.Flush() - case <-keepAlive.C: - fmt.Fprint(writer, ": keep-alive\n\n") - flusher.Flush() - case <-request.Context().Done(): - return - case <-s.done: - return - } - } -} - -func writeSSE(writer http.ResponseWriter, item publication) { - fmt.Fprintf(writer, "id: %d\nevent: snapshot\ndata: %s\n\n", item.id, item.data) -} diff --git a/internal/webapp/static.go b/internal/webapp/static.go new file mode 100644 index 0000000..e81dbe1 --- /dev/null +++ b/internal/webapp/static.go @@ -0,0 +1,65 @@ +package webapp + +import ( + "fmt" + "net/http" +) + +func (s *Server) handleIndex(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || request.URL.Path != "/" { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + http.NotFound(writer, request) + return + } + s.serveStatic(writer, "static/index.html", "text/html; charset=utf-8") +} + +func (s *Server) handleCSS(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.serveStatic(writer, "static/app.css", "text/css; charset=utf-8") +} + +func (s *Server) handleJavaScript(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.serveStatic(writer, "static/app.js", "text/javascript; charset=utf-8") +} + +func (s *Server) serveStatic(writer http.ResponseWriter, name, contentType string) { + content, err := staticFiles.ReadFile(name) + if err != nil { + http.Error(writer, "companion asset unavailable", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", contentType) + _, _ = writer.Write(content) +} + +func (s *Server) handleState(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.mu.Lock() + item, available := s.current, s.hasCurrent + s.mu.Unlock() + if !available { + writer.WriteHeader(http.StatusNoContent) + return + } + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("X-OpsQuest-Event-ID", fmt.Sprint(item.id)) + _, _ = writer.Write(item.data) +}