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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ the exact plan you reviewed.
| Change review | A digest-bound plan shows the exact config, images, host state, rendered Compose, payloads, and operation graph before apply. |
| Deployment | Health-gated rolling replacement drains traffic first and stops on failed readiness. |
| Recovery | Every release records its predecessor; interrupted work can be resumed or aborted, and a failed deploy rolls back by default. |
| Host access | Agentless SSH, key authentication, and mandatory `known_hosts` verification. |
| Host access | Agentless SSH, key authentication, and mandatory `known_hosts` verification. An optional one-hop jump host reaches private targets, with both hops verified and the agent never forwarded. |
| Runtime ownership | Generated Compose stays inspectable with `ob preview` and can be taken over permanently with `ob eject`. |
| Automation | Human output, JSON envelopes, and NDJSON event streams come from the same lifecycle service. |

Expand Down
10 changes: 5 additions & 5 deletions cmd/ob/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -517,11 +517,11 @@ func newUI(cmd *cobra.Command, g *globalFlags) *ui.UI {

// cliConnector is replaceable by in-package tests and honors OB_LOCAL for the
// existing local-docker workflow. Production uses cancellable SSH dialing.
var cliConnector onebox.Connector = func(ctx context.Context, target string) (transport.Transport, error) {
var cliConnector onebox.Connector = func(ctx context.Context, route transport.Route) (transport.Transport, error) {
if value := strings.TrimSpace(strings.ToLower(os.Getenv("OB_LOCAL"))); value == "1" || value == "true" {
return transport.NewLocal(), nil
}
return transport.NewSSHContext(ctx, target)
return transport.NewSSHRoute(ctx, route)
}

func attachTransportLogger(t transport.Transport, logger func(string, string)) {
Expand All @@ -538,8 +538,8 @@ func operationsService(cmd *cobra.Command, g *globalFlags) *onebox.Service {
}

func operationsServiceWithUI(cmd *cobra.Command, g *globalFlags, u *ui.UI) *onebox.Service {
connector := func(ctx context.Context, target string) (transport.Transport, error) {
t, err := cliConnector(ctx, target)
connector := func(ctx context.Context, route transport.Route) (transport.Transport, error) {
t, err := cliConnector(ctx, route)
if err == nil {
attachTransportLogger(t, u.Cmd)
}
Expand Down Expand Up @@ -657,7 +657,7 @@ func connect(cmd *cobra.Command, g *globalFlags, cfg *app.Resolved, p *ctypes.Pr
if err != nil {
return nil, nil, err
}
t, err := cliConnector(cmd.Context(), env.Destination())
t, err := cliConnector(cmd.Context(), env.Route())
if err != nil {
return nil, nil, err
}
Expand Down
6 changes: 3 additions & 3 deletions cmd/ob/ops_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func TestSecretsEditRequiresIDWhenSeveralEntriesExist(t *testing.T) {
func TestExecMissingReasonFailsBeforeTargetContact(t *testing.T) {
previousConnector := cliConnector
contacts := 0
cliConnector = func(context.Context, string) (transport.Transport, error) {
cliConnector = func(context.Context, transport.Route) (transport.Transport, error) {
contacts++
return nil, errors.New("target must not be contacted")
}
Expand All @@ -134,7 +134,7 @@ func TestDestroyConfirmationMismatchIsCancelledBeforeTargetContact(t *testing.T)
config := writeOpsContractProject(t, dir, false)
previousConnector := cliConnector
contacts := 0
cliConnector = func(context.Context, string) (transport.Transport, error) {
cliConnector = func(context.Context, transport.Route) (transport.Transport, error) {
contacts++
return nil, errors.New("target must not be contacted")
}
Expand Down Expand Up @@ -195,7 +195,7 @@ services: {postgres: 17}
}
}}
previousConnector := cliConnector
cliConnector = func(context.Context, string) (transport.Transport, error) { return fake, nil }
cliConnector = func(context.Context, transport.Route) (transport.Transport, error) { return fake, nil }
t.Cleanup(func() { cliConnector = previousConnector })

for name, args := range map[string][]string{
Expand Down
4 changes: 2 additions & 2 deletions cmd/ob/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ func addPreflightCommand(root *cobra.Command, g *globalFlags) {
// User dropped a declared port and connected to 22 instead — a
// silent success against the wrong server, which is worse than
// any failure this command reports.
addr := env.Destination()
addr := env.Route().String()

t, err := transport.NewSSHContext(cmd.Context(), addr)
t, err := transport.NewSSHRoute(cmd.Context(), env.Route())
if err != nil {
return writeStructuredReadFailure(cmd, g, fmt.Errorf("cannot reach %s: %w", addr, err))
}
Expand Down
44 changes: 43 additions & 1 deletion docs/onebox.run-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,48 @@
},
"type": "array"
},
"jump": {
"anyOf": [
{
"type": "string"
},
{
"additionalProperties": false,
"description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent.",
"examples": [
"deploy@bastion.example.com"
],
"patternProperties": {
"^x-": {}
},
"properties": {
"host": {
"description": "Jump host name or IP address.",
"examples": [
"bastion.example.com"
],
"type": "string"
},
"port": {
"description": "SSH port on the jump host. The SSH default is used when omitted.",
"examples": [
2222
],
"type": "integer"
},
"user": {
"description": "SSH user on the jump host. $USER is used when omitted; ob does not read ~/.ssh/config.",
"examples": [
"deploy"
],
"type": "string"
}
},
"type": "object"
}
],
"description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent. Also accepts user@host or user@host:port."
},
"overrides": {
"additionalProperties": false,
"description": "Environment-specific operational tuning. Overrides cannot change workload identity or data semantics.",
Expand Down Expand Up @@ -772,7 +814,7 @@
"type": "integer"
},
"user": {
"description": "SSH user. The local SSH configuration supplies it when omitted.",
"description": "SSH user. $USER is used when omitted; ob does not read ~/.ssh/config.",
"examples": [
"root"
],
Expand Down
1 change: 1 addition & 0 deletions internal/app/jsonschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ var authoredForms = []struct {
{[]string{"workloads", "*", "entrypoint"}, commandForms(), "an entrypoint or argument list"},
{[]string{"workloads", "*", "needs", "items"}, stringForm(), "the name of a prerequisite"},
{[]string{"environments", "*", "server"}, stringForm(), "user@host"},
{[]string{"environments", "*", "jump"}, stringForm(), "user@host or user@host:port"},
{[]string{"runtime", "env_files", "items"}, stringForm(), "a path to an environment file"},
{[]string{"environments", "*", "env_files", "items"}, stringForm(), "a path to an environment file"},
{[]string{"workloads", "*", "env_files", "items"}, stringForm(), "a path to an environment file"},
Expand Down
131 changes: 131 additions & 0 deletions internal/app/jump_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package app

import (
"strings"
"testing"
)

func projectWithJump(jump string) string {
return "api_version: onebox.run/v1\napp: ledger\n" +
"environments: {production: {server: root@10.20.0.10, jump: " + jump + "}}\n" +
"image: nginx\ndomain: ledger.example.com\nport: 8080\n"
}

func TestScalarJumpExpandsToUserHostAndPort(t *testing.T) {
resolved, err := LoadBytes([]byte(projectWithJump("deploy@bastion.example.com:2222")), "ob.yml")
if err != nil {
t.Fatal(err)
}
jump := resolved.Environments["production"].Jump
if jump == nil {
t.Fatal("jump = nil, want the declared bastion")
}
if jump.User != "deploy" || jump.Host != "bastion.example.com" || jump.Port != 2222 {
t.Fatalf("jump = %#v", jump)
}
}

func TestScalarJumpWithoutUserOrPortKeepsThoseImplicit(t *testing.T) {
resolved, err := LoadBytes([]byte(projectWithJump("bastion.example.com")), "ob.yml")
if err != nil {
t.Fatal(err)
}
jump := resolved.Environments["production"].Jump
if jump == nil || jump.User != "" || jump.Host != "bastion.example.com" || jump.Port != 0 {
t.Fatalf("jump = %#v", jump)
}
}

func TestObjectJumpDecodes(t *testing.T) {
resolved, err := LoadBytes([]byte(projectWithJump("{host: bastion.example.com, user: deploy, port: 2222}")), "ob.yml")
if err != nil {
t.Fatal(err)
}
jump := resolved.Environments["production"].Jump
if jump == nil || jump.User != "deploy" || jump.Host != "bastion.example.com" || jump.Port != 2222 {
t.Fatalf("jump = %#v", jump)
}
}

func TestAbsentJumpLeavesTheEnvironmentDirect(t *testing.T) {
resolved, err := LoadBytes([]byte(min), "ob.yml")
if err != nil {
t.Fatal(err)
}
if jump := resolved.Environments["production"].Jump; jump != nil {
t.Fatalf("jump = %#v, want nil", jump)
}
}

// A jump that only fails at dial time is a jump that fails after the operator
// has already been told the plan is sound, so every malformed form is rejected
// while the project is still being read.
func TestInvalidJumpIsRejectedAtLoad(t *testing.T) {
invalid := map[string]string{
"port out of range": "deploy@bastion.example.com:99999",
"port not numeric": "deploy@bastion.example.com:ssh",
"object port high": "{host: bastion.example.com, port: 70000}",
"missing host": "{user: deploy}",
"empty scalar": "\"\"",
"unbracketed ipv6": "deploy@2001:db8::1",
"two at signs": "deploy@bastion@example.com",
"bad user character": "\"bad/user@bastion.example.com\"",
"port inside host": "{host: \"bastion.example.com:2222\"}",
"user inside host": "{host: \"deploy@bastion.example.com\"}",
"bad user object": "{host: bastion.example.com, user: \"bad/user\"}",
}
for name, jump := range invalid {
t.Run(name, func(t *testing.T) {
_, err := LoadBytes([]byte(projectWithJump(jump)), "ob.yml")
if err == nil {
t.Fatalf("jump %q was accepted", jump)
}
if !strings.Contains(err.Error(), "jump") {
t.Fatalf("error does not name the jump field: %v", err)
}
})
}
}

// An IPv6 bastion must be expressible: bracketed in the scalar form, where the
// grammar needs the brackets to find the port, and bare in the object form,
// where each part is already its own field.
func TestIPv6JumpIsAcceptedInBothForms(t *testing.T) {
forms := map[string]string{
"scalar bracketed": `"deploy@[2001:db8::1]"`,
"scalar bracketed port": `"deploy@[2001:db8::1]:2222"`,
"object bare": `{host: "2001:db8::1", user: deploy}`,
// Brackets carried over from the scalar spelling are stripped rather
// than refused: the author meant the address, not a hostname with
// punctuation in it.
"object bracketed": `{host: "[2001:db8::1]", user: deploy}`,
}
for name, form := range forms {
t.Run(name, func(t *testing.T) {
resolved, err := LoadBytes([]byte(projectWithJump(form)), "ob.yml")
if err != nil {
t.Fatal(err)
}
if host := resolved.Environments["production"].Jump.Host; host != "2001:db8::1" {
t.Fatalf("jump host = %q, want the bare literal", host)
}
})
}
}

func TestIPv6JumpRoutesWithBracketsOnlyWhenAPortIsWritten(t *testing.T) {
route := func(form string) string {
t.Helper()
resolved, err := LoadBytes([]byte(projectWithJump(form)), "ob.yml")
if err != nil {
t.Fatal(err)
}
return resolved.Environments["production"].Route().String()
}
if got, want := route(`"deploy@[2001:db8::1]"`), "root@10.20.0.10 via deploy@2001:db8::1"; got != want {
t.Fatalf("route = %q, want %q", got, want)
}
if got, want := route(`"deploy@[2001:db8::1]:2222"`), "root@10.20.0.10 via deploy@[2001:db8::1]:2222"; got != want {
t.Fatalf("route = %q, want %q", got, want)
}
}
63 changes: 55 additions & 8 deletions internal/app/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"

"gopkg.in/yaml.v3"

obtarget "github.com/labstack/onebox/internal/target"
)

// APIVersion is the only authoring contract this package accepts.
Expand Down Expand Up @@ -272,15 +275,17 @@ func expandTopLevelUnions(raw map[string]any) {
}
}
if s, ok := em["server"].(string); ok {
host, user := s, ""
if at := strings.Index(s, "@"); at >= 0 {
user, host = s[:at], s[at+1:]
}
em["server"] = map[string]any{"host": host}
if user != "" {
em["server"].(map[string]any)["user"] = user
}
em["server"] = expandAddress(s)
}
if s, ok := em["jump"].(string); ok {
em["jump"] = expandAddress(s)
}
// The object form is normalised too. Brackets belong to the
// scalar grammar, where they mark off an IPv6 address from its
// port; a host field has no port to mark off, and a bracketed
// value there reaches the dialler as part of the hostname.
unbracketHost(em["server"])
unbracketHost(em["jump"])
}
}
if secrets, ok := raw["secrets"].(map[string]any); ok {
Expand Down Expand Up @@ -735,3 +740,45 @@ func (p *Spec) checkDeclaredFilesExist() error {
}
return nil
}

// expandAddress turns a scalar `[user@]host[:port]` into the object form.
//
// It goes through the shared address grammar rather than splitting on "@" by
// hand, because a hand-rolled split leaves a written port inside the hostname:
// the value still round-trips through Destination(), so it looks correct
// everywhere except at the moment something dials it. A form the grammar
// rejects is left whole as the host, so validation reports it against the
// field the author wrote rather than as a decode error naming a Go type.
func expandAddress(scalar string) map[string]any {
expanded := map[string]any{"host": scalar}
parsed, err := obtarget.Parse(scalar)
if err != nil {
return expanded
}
expanded["host"] = parsed.Host
if parsed.User != "" {
expanded["user"] = parsed.User
}
if parsed.ExplicitPort {
port, _ := strconv.Atoi(parsed.Port)
expanded["port"] = port
}
return expanded
}

// unbracketHost strips the brackets an author may have carried over from the
// scalar spelling of an IPv6 address.
func unbracketHost(value any) {
object, ok := value.(map[string]any)
if !ok {
return
}
host, ok := object["host"].(string)
if !ok || !strings.HasPrefix(host, "[") || !strings.HasSuffix(host, "]") {
return
}
inner := host[1 : len(host)-1]
if strings.Contains(inner, ":") && obtarget.ValidHost(inner) {
object["host"] = inner
}
}
31 changes: 31 additions & 0 deletions internal/app/route.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package app

import (
"strconv"

obtarget "github.com/labstack/onebox/internal/target"
)

// Route is the whole connection this environment needs: the server, and the
// jump host it is reached through when one is declared. It is built from the
// declared fields rather than by reparsing Destination(), so a route without a
// jump renders byte-for-byte what Destination() has always rendered.
func (e Environment) Route() obtarget.Route {
route := obtarget.Route{Target: address(e.Server.User, e.Server.Host, e.Server.Port)}
if e.Jump != nil {
jump := address(e.Jump.User, e.Jump.Host, e.Jump.Port)
route.Jump = &jump
}
return route
}

// address carries the authored port through as explicit and otherwise leaves
// the SSH default implicit, which is the distinction Destination() draws and
// every sealed plan already spells.
func address(user, host string, port int) obtarget.Address {
a := obtarget.Address{User: user, Host: host, Port: "22"}
if port != 0 {
a.Port, a.ExplicitPort = strconv.Itoa(port), true
}
return a
}
Loading