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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## [0.4.0-beta.7] - 2026-08-23

Seventh beta of the Albacore release, focused on reliable first deployments through the CLI.

### Added
- Bind mount ownership declarations and file ownership changes
- One-shot service jobs whose status and output follow the container process
- Partial environment variable updates with plan support
- OpenAPI metadata for accepted values and plan capable operations

### Changed
- Deployment environments use `.env` as the principal file while retaining `.env.flatrun` compatibility
- Environment discovery reports every `.env` file and identifies the principal file
- Deployment images report the resolved image alongside its compose source expression
- Registered database connections provide defaults for database browsing
- PostgreSQL and postgres are accepted as database type names

### Fixed
- Seeded bind mounts retain image ownership, and redeploys no longer apply one service owner to another service's mounts
- PostgreSQL users can create tables in databases provisioned for them
- Routed services join the proxy network before a domain is reported as configured
- Failed domain changes restore deployment metadata and compose configuration
- Deployment and service jobs time out instead of blocking later actions indefinitely
- Nested settings updates preserve omitted sibling values
- Multi-database deployments do not receive misleading legacy database variables
- Compose validation can read environment values supplied in the same create request
- Environment based image updates keep the compose expression and update its variable

## [0.4.0-beta.6] - 2026-08-23

Sixth beta of the Albacore release, making connected servers manageable as one Fleet.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ sudo journalctl -u flatrun-agent -f

Full documentation, guides, and the API reference live at [flatrun.dev/docs](https://flatrun.dev/docs).

The running agent publishes its exact OpenAPI description at `/api/openapi.json`. It includes
request fields, accepted values, permissions, response shapes, and plan support for the installed
version.

## Security

- Use strong, unique API keys.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0-beta.6
0.4.0-beta.7
39 changes: 39 additions & 0 deletions internal/api/database_defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package api

import (
"bytes"
"encoding/json"
"net/http"
"strings"
"testing"
)

func TestDatabaseListUsesRegisteredConnectionWhenBodyIsEmpty(t *testing.T) {
server, _, httpServer := setupPlanTestServer(t)
server.config.Infrastructure.Database.Enabled = true
server.config.Infrastructure.Database.Type = "postgres"
server.config.Infrastructure.Database.Host = "127.0.0.1"
server.config.Infrastructure.Database.Port = 1
server.config.Infrastructure.Database.RootUser = "postgres"
server.config.Infrastructure.Database.RootPassword = "not-a-secret"

request, err := http.NewRequest(http.MethodPost, httpServer.URL+"/api/databases/list", bytes.NewReader(nil))
if err != nil {
t.Fatal(err)
}
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
var body map[string]any
_ = json.NewDecoder(response.Body).Decode(&body)
if response.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, body = %#v", response.StatusCode, body)
}
message, _ := body["error"].(string)
if !strings.Contains(message, "127.0.0.1") || strings.Contains(message, "[::1]:0") {
t.Fatalf("error did not use the registered database: %q", message)
}
}
65 changes: 65 additions & 0 deletions internal/api/deployment_create_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package api

import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)

func TestDeploymentCreateValidatesSuppliedEnvironmentFile(t *testing.T) {
_, deploymentsPath, httpServer := setupPlanTestServer(t)
compose := `services:
app:
image: nginx:alpine
env_file: .env
`
response, body := doJSON(t, http.MethodPost, httpServer.URL+"/api/deployments", map[string]any{
"name": "env-app",
"compose_content": compose,
"env_vars": []map[string]string{
{"key": "APP_MODE", "value": "staging"},
},
})
if response.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, body = %#v", response.StatusCode, body)
}
for _, name := range []string{".env", ".env.flatrun"} {
content, err := os.ReadFile(filepath.Join(deploymentsPath, "env-app", name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if !strings.Contains(string(content), "APP_MODE=staging") {
t.Fatalf("%s = %q", name, content)
}
}
}

func TestDeploymentCreateOmitsLegacyVariablesForMultipleDatabases(t *testing.T) {
_, deploymentsPath, httpServer := setupPlanTestServer(t)
response, body := doJSON(t, http.MethodPost, httpServer.URL+"/api/deployments", map[string]any{
"name": "multi-db-app",
"compose_content": "services:\n app:\n image: nginx:alpine\n",
"databases": []map[string]any{
{"alias": "agent", "type": "postgresql", "mode": "external", "external_host": "agent-db.example.com", "external_port": 5432, "database_name": "agent", "username": "agent", "password": "agent-password"},
{"alias": "gateway", "type": "postgres", "mode": "external", "external_host": "gateway-db.example.com", "external_port": 5432, "database_name": "gateway", "username": "gateway", "password": "gateway-password"},
},
})
if response.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, body = %#v", response.StatusCode, body)
}
content, err := os.ReadFile(filepath.Join(deploymentsPath, "multi-db-app", ".env"))
if err != nil {
t.Fatal(err)
}
text := string(content)
for _, expected := range []string{"AGENT_HOST=agent-db.example.com", "GATEWAY_HOST=gateway-db.example.com"} {
if !strings.Contains(text, expected) {
t.Fatalf("environment is missing %q: %s", expected, text)
}
}
if strings.Contains(text, "\nDB_HOST=") || strings.HasPrefix(text, "DB_HOST=") {
t.Fatalf("legacy database variables must be absent: %s", text)
}
}
12 changes: 12 additions & 0 deletions internal/api/domains_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"

"github.com/flatrun/agent/internal/docker"
"github.com/flatrun/agent/pkg/config"
"github.com/flatrun/agent/pkg/models"
)

Expand All @@ -28,6 +30,9 @@ func setupDomainsTestServer(t *testing.T) (*Server, string, func()) {

server := &Server{
manager: manager,
config: &config.Config{Infrastructure: config.InfrastructureConfig{
DefaultProxyNetwork: "proxy",
}},
}

cleanup := func() {
Expand Down Expand Up @@ -380,6 +385,13 @@ func TestAddDomain(t *testing.T) {
if metadata.Domains[0].ID == "" {
t.Error("expected domain ID to be generated")
}
compose, _, err := server.manager.GetComposeFile("add-domain")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(compose, "- proxy") || !strings.Contains(compose, "proxy:\n external: true") {
t.Fatalf("routed service did not join proxy network: %s", compose)
}
})

t.Run("rejects duplicate domain", func(t *testing.T) {
Expand Down
30 changes: 30 additions & 0 deletions internal/api/file_ownership_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package api

import (
"net/http"
"os"
"path/filepath"
"testing"
)

func TestDeploymentFileOwnershipUpdatesThroughHTTP(t *testing.T) {
_, deploymentsPath, httpServer := setupPlanTestServer(t)
target := filepath.Join(deploymentsPath, "owned-app", "data")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}

response, body := doJSON(t, http.MethodPut, httpServer.URL+"/api/deployments/owned-app/permissions/data", map[string]any{
"uid": os.Geteuid(), "gid": os.Getegid(), "recursive": true,
})
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %#v", response.StatusCode, body)
}

response, body = doJSON(t, http.MethodPut, httpServer.URL+"/api/deployments/owned-app/permissions/", map[string]any{
"uid": os.Geteuid(), "gid": os.Getegid(),
})
if response.StatusCode != http.StatusInternalServerError || body["error"] != "deployment root ownership cannot be changed" {
t.Fatalf("root status = %d, body = %#v", response.StatusCode, body)
}
}
40 changes: 40 additions & 0 deletions internal/api/jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,46 @@ func TestServiceJobThreadsEffectiveApplyOptions(t *testing.T) {
}
}

func TestServiceRunJobUsesOneShotExecution(t *testing.T) {
s := newJobTestServer(&fakeRunner{})
action := make(chan string, 1)
s.runServiceAction = func(got, _, _ string, _ actionOptions, emit func(string)) error {
action <- got
emit("migration complete")
return nil
}
srv := newSkippableHTTPServer(t, newJobRouter(s))
defer srv.Close()

resp, err := http.Post(
srv.URL+"/api/deployments/app/services/migrate/job",
"application/json",
strings.NewReader(`{"action":"run"}`),
)
if err != nil {
t.Fatalf("service run request failed: %v", err)
}
defer resp.Body.Close()
var body map[string]any
_ = json.NewDecoder(resp.Body).Decode(&body)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("expected 202, got %d: %v", resp.StatusCode, body)
}

select {
case got := <-action:
if got != "run" {
t.Fatalf("action = %q, want run", got)
}
case <-time.After(5 * time.Second):
t.Fatal("service run was not invoked")
}
snap := pollJob(t, srv.URL, "app", body["job_id"].(string))
if snap.Status != JobSucceeded || snap.Output != "migration complete" {
t.Fatalf("job = %+v", snap)
}
}

func TestDeploymentJobStreamReplaysAndCompletes(t *testing.T) {
s := &Server{
jobs: newJobRegistry(),
Expand Down
Loading
Loading