Skip to content
Open
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
91 changes: 91 additions & 0 deletions spring-boot-admin-server-ui/src/main/frontend/store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,97 @@ describe('store', () => {
expect(removedListener).not.toHaveBeenCalled();
});

it('handles instance rename: migrates instance to new application and removes the empty old one', async () => {
// The backend SSE stream re-publishes the new application (with the migrated
// instance) and then the previous application with an empty instance list so
// the store can drop it. The instance's registration.name changes while the
// id/healthUrl stay the same.
const instance = { ...registerWithOneInstance.instances[0] };
instance.registration = {
...instance.registration,
name: 'new-service',
};
const newService = { ...registerWithOneInstance, name: 'new-service', instances: [instance] };
const oldServiceEmpty = { ...registerWithOneInstance, name: 'old-service', instances: [] };

// Seed the store with the application under its previous name.
const oldService = { ...registerWithOneInstance, name: 'old-service' };
mockSubject.next({ data: oldService });

await waitFor(() => {
expect(applicationStore.applications).toHaveLength(1);
expect(applicationStore.applications[0].name).toBe('old-service');
});

// Simulate the rename update sequence emitted by the backend: new-service
// gains the instance, old-service ends up empty and must be removed.
mockSubject.next({ data: newService });
mockSubject.next({ data: oldServiceEmpty });

await waitFor(() => {
expect(applicationStore.applications).toHaveLength(1);
const app = applicationStore.applications[0];
expect(app.name).toBe('new-service');
expect(app.instances).toHaveLength(1);
expect(app.instances[0].id).toBe(instance.id);
});

expect(removedListener).toHaveBeenCalled();
const removedName = removedListener.mock.calls[removedListener.mock.calls.length - 1][0].name;
expect(removedName).toBe('old-service');
});

it('handles rename when old application still has other instances: updates both groups, removes none', async () => {
const migratedInstance = {
...registerWithOneInstance.instances[0],
id: 'instance-a',
registration: {
...registerWithOneInstance.instances[0].registration,
name: 'new-service',
healthUrl: 'http://localhost:8080/actuator/health',
},
};
const remainingInstance = {
...registerWithOneInstance.instances[0],
id: 'instance-b',
registration: {
...registerWithOneInstance.instances[0].registration,
name: 'old-service',
healthUrl: 'http://localhost:8081/actuator/health',
},
};

const oldServiceWithTwo = {
...registerWithOneInstance,
name: 'old-service',
instances: [
{ ...migratedInstance, registration: { ...migratedInstance.registration, name: 'old-service' } },
remainingInstance,
],
};
const newService = { ...registerWithOneInstance, name: 'new-service', instances: [migratedInstance] };
const oldServiceWithOne = { ...registerWithOneInstance, name: 'old-service', instances: [remainingInstance] };

mockSubject.next({ data: oldServiceWithTwo });
await waitFor(() => {
expect(applicationStore.applications).toHaveLength(1);
expect(applicationStore.applications[0].instances).toHaveLength(2);
});

mockSubject.next({ data: newService });
mockSubject.next({ data: oldServiceWithOne });

await waitFor(() => {
const names = applicationStore.applications.map((a) => a.name).sort();
expect(names).toEqual(['new-service', 'old-service']);
const oldApp = applicationStore.applications.find((a) => a.name === 'old-service');
expect(oldApp.instances).toHaveLength(1);
expect(oldApp.instances[0].id).toBe('instance-b');
});

expect(removedListener).not.toHaveBeenCalled();
});

it('removes an application', async () => {
mockSubject.next({ data: registerWithOneInstance });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ public Instance register(Registration registration) {
}

if (!Objects.equals(this.registration, registration)) {
return this.apply(new InstanceRegistrationUpdatedEvent(this.id, this.nextVersion(), registration), true);
return this.apply(
new InstanceRegistrationUpdatedEvent(this.id, this.nextVersion(), registration, this.registration),
true);
}

return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@
import java.io.Serial;
import java.time.Instant;

import org.jspecify.annotations.Nullable;

import de.codecentric.boot.admin.server.domain.values.InstanceId;
import de.codecentric.boot.admin.server.domain.values.Registration;

/**
* This event gets emitted when an instance updates it's registration.
* <p>
* The optional {@link #getPrevious() previous} registration holds the registration as it
* was <em>before</em> the update. This allows listeners to detect an application rename
* (i.e. when the instance id stays the same but {@link Registration#getName()} changes).
* When no previous registration is known the field is {@code null}.
*
* @author Johannes Edmeier
*/
Expand All @@ -39,14 +46,27 @@ public class InstanceRegistrationUpdatedEvent extends InstanceEvent {

Registration registration;

@Nullable Registration previous;

public InstanceRegistrationUpdatedEvent(InstanceId instance, long version, Registration registration) {
this(instance, version, Instant.now(), registration);
this(instance, version, Instant.now(), registration, null);
}

public InstanceRegistrationUpdatedEvent(InstanceId instance, long version, Instant timestamp,
Registration registration) {
this(instance, version, timestamp, registration, null);
}

public InstanceRegistrationUpdatedEvent(InstanceId instance, long version, Registration registration,
@Nullable Registration previous) {
this(instance, version, Instant.now(), registration, previous);
}

public InstanceRegistrationUpdatedEvent(InstanceId instance, long version, Instant timestamp,
Registration registration, @Nullable Registration previous) {
super(instance, version, TYPE, timestamp);
this.registration = registration;
this.previous = previous;
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@

import de.codecentric.boot.admin.server.domain.entities.Application;
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.events.InstanceEvent;
import de.codecentric.boot.admin.server.domain.events.InstanceRegistrationUpdatedEvent;
import de.codecentric.boot.admin.server.domain.values.BuildVersion;
import de.codecentric.boot.admin.server.domain.values.InstanceId;
import de.codecentric.boot.admin.server.domain.values.Registration;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;
import de.codecentric.boot.admin.server.eventstore.InstanceEventPublisher;

Expand Down Expand Up @@ -79,18 +82,49 @@ public Mono<Application> getApplication(String name) {

public Flux<Application> getApplicationStream() {
return Flux.from(this.instanceEventPublisher)
.flatMap((event) -> this.instanceRegistry.getInstance(event.getInstance()))
.map(this::getApplicationForInstance)
.flatMap(this::resolveAffectedApplicationGroups)
.flatMap((group) -> toApplication(group.getT1(), group.getT2()));
}

/**
* Resolves the application groups that are affected by the given event.
* <p>
* For most events this is only the application the instance currently belongs to.
* When an {@link InstanceRegistrationUpdatedEvent} indicates that an instance was
* renamed (i.e. the previous registration carries a different name) the previously
* associated application is included as well so that it gets re-aggregated. If no
* instances remain under the previous name the resulting {@link Application} will
* have an empty instance list which is the signal for clients to remove it.
* @param event the event that was published
* @return a flux of name / instances tuples that need to be (re-)published
*/
protected Flux<Tuple2<String, Flux<Instance>>> resolveAffectedApplicationGroups(InstanceEvent event) {
return this.instanceRegistry.getInstance(event.getInstance()).flatMapMany((instance) -> {
Flux<Tuple2<String, Flux<Instance>>> groups = Flux.just(getApplicationForInstance(instance));

if (event instanceof InstanceRegistrationUpdatedEvent updatedEvent) {
Registration previous = updatedEvent.getPrevious();
String currentName = instance.getRegistration().getName();
if (previous != null && !Objects.equals(previous.getName(), currentName)) {
groups = Flux.merge(groups, Flux.just(getApplicationForName(previous.getName())));
}
}

return groups;
});
}

public Flux<InstanceId> deregister(String name) {
return this.instanceRegistry.getInstances(name)
.flatMap((instance) -> this.instanceRegistry.deregister(instance.getId()));
}

protected Tuple2<String, Flux<Instance>> getApplicationForInstance(Instance instance) {
String name = instance.getRegistration().getName();
return getApplicationForName(name);
}

protected Tuple2<String, Flux<Instance>> getApplicationForName(String name) {
return Tuples.of(name, this.instanceRegistry.getInstances(name).filter(Instance::isRegistered));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.jspecify.annotations.Nullable;

import de.codecentric.boot.admin.server.domain.events.InstanceRegistrationUpdatedEvent;
import de.codecentric.boot.admin.server.domain.values.InstanceId;
Expand All @@ -36,7 +37,8 @@ public abstract class InstanceRegistrationUpdatedEventMixin {
@JsonCreator
public InstanceRegistrationUpdatedEventMixin(@JsonProperty("instance") InstanceId instance,
@JsonProperty("version") long version, @JsonProperty("timestamp") Instant timestamp,
@JsonProperty("registration") Registration registration) {
@JsonProperty("registration") Registration registration,
@JsonProperty("previous") @Nullable Registration previous) {
}

}
}
Loading