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
3 changes: 2 additions & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ vallic login --token
vallic whoami
vallic team

# Record which project this directory is, outside a clone of its repository
# Record a project for directories that are not a clone of its repository.
# One for the whole machine: a clone's own remote still answers first.
vallic link acme-site

# What this checkout points at: project, environment, branch, last deploy
Expand Down
18 changes: 18 additions & 0 deletions internal/api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ type EnvironmentDetail struct {
// Hostnames is what the environment answers on. Nil only against a
// control plane that predates the member.
Hostnames *Hostnames `json:"hostnames"`

// Servers are the machines the environment runs on, by the IDs `server
// list` prints, limited to those the caller may see. Not nil where SSH
// is: staff outside the team get no shell target and still need the
// machine. Nil only against a control plane that predates the member.
Servers []EnvironmentServer `json:"servers"`
}

// EnvironmentServer is one placement of an environment on a machine.
type EnvironmentServer struct {
ID int `json:"id"`
Role string `json:"role"`
}

// Hostnames is where an environment can be reached.
Expand Down Expand Up @@ -372,6 +384,12 @@ type Deployment struct {

// SkipSteps says it goes live without the application's deploy steps.
SkipSteps bool `json:"skip_steps"`

// Task is the work doing it, whose log is the deploy's. Not the
// deployment's own id: the two are numbered apart, and following the
// deployment's id as a task's found another task or none. Nil against a
// control plane that predates the member, and where nothing was queued.
Task *int `json:"task"`
}

// Describe says what happened, in the words the trigger calls for.
Expand Down
16 changes: 16 additions & 0 deletions internal/api/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,19 @@ func TestARetryAfterSurvivesAResponseWithNoEnvelope(t *testing.T) {
t.Errorf("RetryAfter = %v, want 7s", apiErr.RetryAfter)
}
}

// `--format json` re-encodes these structs, so a member the struct lacks is a
// member the CLI's JSON drops.
func TestEnvironmentServersSurviveReencoding(t *testing.T) {
var detail EnvironmentDetail
if err := json.Unmarshal([]byte(`{"id":9,"ssh":null,"servers":[{"id":42,"role":"web"},{"id":43,"role":"db"}]}`), &detail); err != nil {
t.Fatal(err)
}
out, err := json.Marshal(detail)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), `"servers":[{"id":42,"role":"web"},{"id":43,"role":"db"}]`) {
t.Fatalf("servers lost: %s", out)
}
}
2 changes: 1 addition & 1 deletion internal/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ set. The output says which it is going to be.`,
// after. `build --deploy` skipped it until [2026-09-30], which
// made the flag a way round a question the other path asks.
if asksBeforeShipping(deploy, detail.Protected, env.Interactive()) {
if !env.confirm(fmt.Sprintf("Build and deploy to %s, which is protected?", detail.Name)) {
if !env.confirm(fmt.Sprintf("Build and deploy to %s, which is protected?", target.Named())) {
return fmt.Errorf("cancelled")
}
}
Expand Down
19 changes: 15 additions & 4 deletions internal/cli/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ the whole of the check.`,
// environment, not operating one — but a `vallic deploy` run
// from the wrong checkout should be recoverable before it
// happens rather than after.
if !env.confirm(fmt.Sprintf("Deploy to %s, which is protected?", detail.Name)) {
if !env.confirm(fmt.Sprintf("Deploy to %s, which is protected?", target.Named())) {
return fmt.Errorf("cancelled")
}
}
Expand Down Expand Up @@ -160,9 +160,20 @@ func (e *Env) waitForDeployment(ctx context.Context, deployment *api.Deployment,
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

e.Printer.Say("Waiting for deployment %d…", deployment.ID)
// The task the control plane says is doing it, and never the deployment's
// own id: the two are numbered apart, and following that one found
// another task's log, or none. A control plane that does not say cannot
// be followed from here, and a wait that guessed would report another
// task's ending as this deploy's.
if deployment.Task == nil {
return fmt.Errorf("deployment %d is queued, but this control plane does not say which task runs it; follow it with `vallic activity list`", deployment.ID)
}

id := *deployment.Task

e.Printer.Say("Waiting for deployment %d (task %d)…", deployment.ID, id)

state, err := e.follow(ctx, client, deployment.ID, e.Err)
state, err := e.follow(ctx, client, id, e.Err)
if err != nil {
return err
}
Expand All @@ -171,7 +182,7 @@ func (e *Env) waitForDeployment(ctx context.Context, deployment *api.Deployment,
// log route carries the state so the loop above needs one request per
// tick; it deliberately does not carry the rest, because that is the
// task's contract and not the log's.
task, err := client.Task(ctx, deployment.ID)
task, err := client.Task(ctx, id)
if err != nil {
// The work finished and this is only the epilogue failing. Reporting
// the state we already saw beats turning a successful deploy into an
Expand Down
61 changes: 61 additions & 0 deletions internal/cli/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,67 @@ func TestAFollowRidesOutARateLimitInsteadOfAbandoningTheDeploy(t *testing.T) {
}
}

// A wait follows the task the control plane names, not the deployment's id.
//
// The two are numbered apart. A wait that asked for the deployment's id as a
// task's was told there was no such task — or, had one existed, would have
// reported its ending as this deploy's.
func TestAWaitFollowsTheDeploymentsTask(t *testing.T) {
var elsewhere int64

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

switch {
case strings.HasSuffix(r.URL.Path, "/tasks/40/log"):
_, _ = w.Write([]byte(`{"log":{"offset":0,"content":"live\n","next_offset":5,` +
`"size":5,"truncated":false,"state":"succeeded","complete":true}}`))
case strings.HasSuffix(r.URL.Path, "/tasks/40"):
_, _ = w.Write([]byte(`{"task":{"id":40,"type":"deploy","state":"succeeded"}}`))
default:
atomic.AddInt64(&elsewhere, 1)
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"No such task."}}`))
}
}))
defer server.Close()

env, _, errOut := followEnv(t)
env.client = api.New(server.URL, auth.Anonymous{}, "test")

task := 40
if err := env.waitForDeployment(context.Background(), &api.Deployment{ID: 12, Task: &task}, 10*time.Second); err != nil {
t.Fatalf("waitForDeployment: %v", err)
}

if n := atomic.LoadInt64(&elsewhere); n != 0 {
t.Errorf("%d request(s) went to a task other than 40", n)
}

if !strings.Contains(errOut.String(), "task 40") {
t.Errorf("stderr = %q, want it to say which task it follows", errOut.String())
}
}

// A control plane that does not name the task is not guessed at.
//
// Following the deployment's id instead is the defect: it reports another
// task's ending, or none, as this deploy's.
func TestAWaitWithoutATaskIsRefused(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("a wait with no task asked the control plane for one anyway")
}))
defer server.Close()

env, _, _ := followEnv(t)
env.client = api.New(server.URL, auth.Anonymous{}, "test")

err := env.waitForDeployment(context.Background(), &api.Deployment{ID: 12}, 10*time.Second)
if err == nil || !strings.Contains(err.Error(), "vallic activity list") {
t.Fatalf("err = %v, want a refusal that says where to look", err)
}
}

// followEnv is an Env that writes where a test can read it.
func followEnv(t *testing.T) (*Env, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
Expand Down
Loading
Loading